millas 0.2.12-beta-1 → 0.2.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 +3 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +143 -74
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- package/src/validation/Validator.js +348 -607
package/src/admin/AdminAuth.js
CHANGED
|
@@ -1,48 +1,57 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
-
const bcrypt = require('bcryptjs');
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* AdminAuth
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* Authentication for the Millas admin panel.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
* or database required. The cookie payload is HMAC-signed with
|
|
13
|
-
* APP_KEY so it cannot be forged.
|
|
10
|
+
* ── How it works (Django parity) ─────────────────────────────────────────────
|
|
14
11
|
*
|
|
15
|
-
*
|
|
12
|
+
* 1. Login: find user by email in the app's User model (same table the API uses).
|
|
13
|
+
* Check password with bcrypt. Require is_active=true AND is_staff=true.
|
|
14
|
+
* On success, store only { id } in a signed, httpOnly cookie — never the
|
|
15
|
+
* full user object.
|
|
16
|
+
*
|
|
17
|
+
* 2. Every request: read { id } from the cookie, call User.find(id) to get a
|
|
18
|
+
* live user record. Attach it as req.adminUser. If the user has been
|
|
19
|
+
* deactivated since their last request, they are immediately locked out —
|
|
20
|
+
* no need to wait for session expiry.
|
|
21
|
+
*
|
|
22
|
+
* 3. is_staff gate: only users with is_staff=true can enter the admin.
|
|
23
|
+
* is_superuser=true bypasses all resource-level permission checks (Phase 6).
|
|
24
|
+
*
|
|
25
|
+
* ── Configuration ────────────────────────────────────────────────────────────
|
|
16
26
|
*
|
|
17
27
|
* Admin.configure({
|
|
18
28
|
* auth: {
|
|
19
|
-
* //
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* ],
|
|
23
|
-
*
|
|
24
|
-
* // OR: use a Model — any model with email + password fields
|
|
29
|
+
* // Optional — AdminServiceProvider resolves this automatically from
|
|
30
|
+
* // app/models/User (falling back to the built-in AuthUser).
|
|
31
|
+
* // Only set this if you want to override the model explicitly.
|
|
25
32
|
* model: UserModel,
|
|
26
33
|
*
|
|
27
|
-
* //
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* // Rate limiting (per IP)
|
|
33
|
-
* maxAttempts: 5,
|
|
34
|
+
* cookieName: 'millas_admin', // default
|
|
35
|
+
* cookieMaxAge: 60 * 60 * 8, // 8 hours (seconds)
|
|
36
|
+
* rememberAge: 60 * 60 * 24 * 30, // 30 days ("remember me")
|
|
37
|
+
* maxAttempts: 5,
|
|
34
38
|
* lockoutMinutes: 15,
|
|
35
39
|
* }
|
|
36
40
|
* });
|
|
37
41
|
*
|
|
38
|
-
* Disable auth entirely:
|
|
42
|
+
* Disable auth entirely (not recommended):
|
|
39
43
|
* Admin.configure({ auth: false });
|
|
40
44
|
*/
|
|
41
45
|
class AdminAuth {
|
|
42
46
|
constructor() {
|
|
43
|
-
this._config
|
|
47
|
+
this._config = null;
|
|
48
|
+
this._UserModel = null; // resolved by AdminServiceProvider
|
|
49
|
+
this._basePath = null; // resolved by AdminServiceProvider.setBasePath()
|
|
50
|
+
this._attempts = new Map();
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
// ─── Configuration ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
46
55
|
configure(authConfig) {
|
|
47
56
|
if (authConfig === false) {
|
|
48
57
|
this._config = false;
|
|
@@ -50,8 +59,7 @@ class AdminAuth {
|
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
this._config = {
|
|
53
|
-
|
|
54
|
-
model: null,
|
|
62
|
+
model: null, // overridden by setUserModel() or config
|
|
55
63
|
cookieName: 'millas_admin',
|
|
56
64
|
cookieMaxAge: 60 * 60 * 8,
|
|
57
65
|
rememberAge: 60 * 60 * 24 * 30,
|
|
@@ -60,10 +68,33 @@ class AdminAuth {
|
|
|
60
68
|
...authConfig,
|
|
61
69
|
};
|
|
62
70
|
|
|
63
|
-
//
|
|
71
|
+
// If the config block supplied an explicit model, use it
|
|
72
|
+
if (this._config.model) {
|
|
73
|
+
this._UserModel = this._config.model;
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
this._attempts = new Map();
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Called by AdminServiceProvider after Auth is booted.
|
|
81
|
+
* Provides the resolved User model (app/models/User or AuthUser fallback).
|
|
82
|
+
* Only applied if no explicit model was set in the auth config block.
|
|
83
|
+
*/
|
|
84
|
+
setUserModel(UserModel) {
|
|
85
|
+
if (!this._UserModel) {
|
|
86
|
+
this._UserModel = UserModel;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Called by AdminServiceProvider to provide the project basePath.
|
|
92
|
+
* Used by _resolveUserModel() so it never calls process.cwd() at request time.
|
|
93
|
+
*/
|
|
94
|
+
setBasePath(basePath) {
|
|
95
|
+
this._basePath = basePath || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
/** Returns true if auth is enabled. */
|
|
68
99
|
get enabled() {
|
|
69
100
|
return this._config !== null && this._config !== false;
|
|
@@ -72,83 +103,120 @@ class AdminAuth {
|
|
|
72
103
|
// ─── Middleware ────────────────────────────────────────────────────────────
|
|
73
104
|
|
|
74
105
|
/**
|
|
75
|
-
* Express middleware —
|
|
76
|
-
*
|
|
106
|
+
* Express middleware — runs before every admin route.
|
|
107
|
+
*
|
|
108
|
+
* Verifies the signed session cookie, loads the live user from DB,
|
|
109
|
+
* checks is_active + is_staff, attaches to req.adminUser.
|
|
110
|
+
* Redirects to login if any check fails.
|
|
77
111
|
*/
|
|
78
112
|
middleware(prefix) {
|
|
79
|
-
return (req, res, next) => {
|
|
113
|
+
return async (req, res, next) => {
|
|
80
114
|
if (!this.enabled) return next();
|
|
81
115
|
|
|
82
116
|
const loginPath = `${prefix}/login`;
|
|
117
|
+
const p = req.path;
|
|
83
118
|
|
|
84
|
-
// Always
|
|
85
|
-
if (
|
|
86
|
-
if (req.path === '/logout' || req.path === `${prefix}/logout`) return next();
|
|
119
|
+
// Always let login/logout through
|
|
120
|
+
if (p === '/login' || p === '/logout') return next();
|
|
87
121
|
|
|
88
|
-
const
|
|
89
|
-
if (!
|
|
122
|
+
const session = this._getSession(req);
|
|
123
|
+
if (!session) {
|
|
90
124
|
const returnTo = encodeURIComponent(req.originalUrl);
|
|
91
125
|
return res.redirect(`${loginPath}?next=${returnTo}`);
|
|
92
126
|
}
|
|
93
127
|
|
|
128
|
+
// Live user lookup — deactivated users are locked out immediately
|
|
129
|
+
const user = await this._loadUser(session.id);
|
|
130
|
+
if (!user) {
|
|
131
|
+
this._clearSessionCookie(res);
|
|
132
|
+
return res.redirect(`${loginPath}?next=${encodeURIComponent(req.originalUrl)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!user.is_active) {
|
|
136
|
+
this._clearSessionCookie(res);
|
|
137
|
+
this.setFlash(res, 'error', 'This account is inactive.');
|
|
138
|
+
return res.redirect(loginPath);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!user.is_staff) {
|
|
142
|
+
this._clearSessionCookie(res);
|
|
143
|
+
this.setFlash(res, 'error', 'You do not have staff access to this admin.');
|
|
144
|
+
return res.redirect(loginPath);
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
req.adminUser = user;
|
|
95
148
|
next();
|
|
96
149
|
};
|
|
97
150
|
}
|
|
98
151
|
|
|
99
|
-
// ─── Login
|
|
152
|
+
// ─── Login ────────────────────────────────────────────────────────────────
|
|
100
153
|
|
|
101
154
|
/**
|
|
102
155
|
* Attempt to log in with email + password.
|
|
103
|
-
*
|
|
156
|
+
* Enforces is_active + is_staff. Throws on failure.
|
|
157
|
+
* On success, writes a signed session cookie containing only { id }.
|
|
104
158
|
*/
|
|
105
159
|
async login(req, res, { email, password, remember = false }) {
|
|
106
|
-
if (!this.enabled) return
|
|
160
|
+
if (!this.enabled) return null;
|
|
107
161
|
|
|
108
162
|
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
|
|
109
163
|
this._checkRateLimit(ip);
|
|
110
164
|
|
|
111
|
-
const
|
|
165
|
+
const normalised = (email || '').trim().toLowerCase();
|
|
166
|
+
const user = await this._loadUserByEmail(normalised);
|
|
167
|
+
|
|
168
|
+
// Always check password first — avoids leaking account existence
|
|
169
|
+
const Hasher = require('../auth/Hasher');
|
|
170
|
+
const validPassword = user ? await Hasher.check(password, user.password) : false;
|
|
112
171
|
|
|
113
|
-
if (!user || !
|
|
172
|
+
if (!user || !validPassword) {
|
|
114
173
|
this._recordFailedAttempt(ip);
|
|
115
|
-
throw new Error('
|
|
174
|
+
throw new Error('Please enter the correct email and password for a staff account. Note that both fields may be case-sensitive.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!user.is_active) {
|
|
178
|
+
throw new Error('This account is inactive.');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!user.is_staff) {
|
|
182
|
+
// Exactly what Django says
|
|
183
|
+
throw new Error('Please enter the correct email and password for a staff account. Note that both fields may be case-sensitive.');
|
|
116
184
|
}
|
|
117
185
|
|
|
118
186
|
this._clearAttempts(ip);
|
|
119
187
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
188
|
+
// Update last_login (fire-and-forget)
|
|
189
|
+
try {
|
|
190
|
+
await this._UserModel.where('id', user.id).update({ last_login: new Date().toISOString() });
|
|
191
|
+
} catch { /* non-fatal */ }
|
|
123
192
|
|
|
124
|
-
|
|
193
|
+
const maxAge = remember ? this._config.rememberAge : this._config.cookieMaxAge;
|
|
194
|
+
// Store only the PK — everything else is loaded fresh per request
|
|
195
|
+
this._setSession(res, { id: user.id }, maxAge);
|
|
125
196
|
|
|
126
197
|
return user;
|
|
127
198
|
}
|
|
128
199
|
|
|
129
200
|
/** Destroy the admin session cookie. */
|
|
130
201
|
logout(res) {
|
|
131
|
-
|
|
202
|
+
this._clearSessionCookie(res);
|
|
132
203
|
}
|
|
133
204
|
|
|
134
205
|
// ─── Flash (cookie-based) ─────────────────────────────────────────────────
|
|
135
206
|
|
|
136
|
-
/** Store a flash message in a short-lived cookie. */
|
|
137
207
|
setFlash(res, type, message) {
|
|
138
208
|
const payload = JSON.stringify({ type, message });
|
|
139
209
|
res.cookie('millas_flash', Buffer.from(payload).toString('base64'), {
|
|
140
210
|
httpOnly: true,
|
|
141
|
-
maxAge: 10 * 1000,
|
|
211
|
+
maxAge: 10 * 1000,
|
|
142
212
|
path: '/',
|
|
143
213
|
sameSite: 'lax',
|
|
144
214
|
});
|
|
145
215
|
}
|
|
146
216
|
|
|
147
|
-
/** Read and clear the flash cookie. */
|
|
148
217
|
getFlash(req, res) {
|
|
149
218
|
const raw = this._parseCookies(req)['millas_flash'];
|
|
150
219
|
if (!raw) return {};
|
|
151
|
-
// Clear it immediately
|
|
152
220
|
res.clearCookie('millas_flash', { path: '/' });
|
|
153
221
|
try {
|
|
154
222
|
const { type, message } = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
|
|
@@ -156,26 +224,24 @@ class AdminAuth {
|
|
|
156
224
|
} catch { return {}; }
|
|
157
225
|
}
|
|
158
226
|
|
|
159
|
-
// ─── Session
|
|
227
|
+
// ─── Session ──────────────────────────────────────────────────────────────
|
|
160
228
|
|
|
161
229
|
_setSession(res, payload, maxAge) {
|
|
162
|
-
const name
|
|
163
|
-
const data
|
|
164
|
-
const sig
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
res.cookie(name, value, {
|
|
230
|
+
const name = this._config.cookieName;
|
|
231
|
+
const data = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
232
|
+
const sig = this._sign(data);
|
|
233
|
+
res.cookie(name, `${data}.${sig}`, {
|
|
168
234
|
httpOnly: true,
|
|
169
235
|
maxAge: maxAge * 1000,
|
|
170
236
|
path: '/',
|
|
171
237
|
sameSite: 'lax',
|
|
172
|
-
// secure: true
|
|
238
|
+
// secure: true — enable in production behind HTTPS
|
|
173
239
|
});
|
|
174
240
|
}
|
|
175
241
|
|
|
176
242
|
_getSession(req) {
|
|
177
|
-
const name
|
|
178
|
-
const raw
|
|
243
|
+
const name = this._config?.cookieName || 'millas_admin';
|
|
244
|
+
const raw = this._parseCookies(req)[name];
|
|
179
245
|
if (!raw) return null;
|
|
180
246
|
|
|
181
247
|
const dot = raw.lastIndexOf('.');
|
|
@@ -183,7 +249,6 @@ class AdminAuth {
|
|
|
183
249
|
|
|
184
250
|
const data = raw.slice(0, dot);
|
|
185
251
|
const sig = raw.slice(dot + 1);
|
|
186
|
-
|
|
187
252
|
if (sig !== this._sign(data)) return null;
|
|
188
253
|
|
|
189
254
|
try {
|
|
@@ -191,91 +256,141 @@ class AdminAuth {
|
|
|
191
256
|
} catch { return null; }
|
|
192
257
|
}
|
|
193
258
|
|
|
259
|
+
_clearSessionCookie(res) {
|
|
260
|
+
const name = this._config?.cookieName || 'millas_admin';
|
|
261
|
+
res.clearCookie(name, { path: '/' });
|
|
262
|
+
}
|
|
263
|
+
|
|
194
264
|
_sign(data) {
|
|
195
265
|
const secret = process.env.APP_KEY || 'millas-admin-secret-change-me';
|
|
196
266
|
return crypto.createHmac('sha256', secret).update(data).digest('hex').slice(0, 32);
|
|
197
267
|
}
|
|
198
268
|
|
|
269
|
+
// ─── CSRF ─────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Generate a CSRF token tied to the current session.
|
|
273
|
+
* Token = HMAC(sessionId + timestamp_hour) so it rotates hourly
|
|
274
|
+
* but stays valid for the full hour — no per-request token storage needed.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} req
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
csrfToken(req) {
|
|
280
|
+
const session = this._getSession(req);
|
|
281
|
+
const hourSlot = Math.floor(Date.now() / (1000 * 60 * 60)); // changes every hour
|
|
282
|
+
const payload = `csrf:${session?.id || 'anon'}:${hourSlot}`;
|
|
283
|
+
return this._sign(payload);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Verify a CSRF token submitted with a form.
|
|
288
|
+
* Accepts tokens from the current hour OR the previous hour (grace period).
|
|
289
|
+
*
|
|
290
|
+
* @param {object} req
|
|
291
|
+
* @param {string} token — value from req.body._csrf or X-CSRF-Token header
|
|
292
|
+
* @returns {boolean}
|
|
293
|
+
*/
|
|
294
|
+
verifyCsrf(req, token) {
|
|
295
|
+
if (!token) return false;
|
|
296
|
+
const session = this._getSession(req);
|
|
297
|
+
const hourSlot = Math.floor(Date.now() / (1000 * 60 * 60));
|
|
298
|
+
// Check current hour and previous hour (grace period for forms submitted near the boundary)
|
|
299
|
+
for (const slot of [hourSlot, hourSlot - 1]) {
|
|
300
|
+
const payload = `csrf:${session?.id || 'anon'}:${slot}`;
|
|
301
|
+
if (token === this._sign(payload)) return true;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
199
306
|
_parseCookies(req) {
|
|
200
|
-
const header = req.headers.cookie || '';
|
|
201
307
|
const result = {};
|
|
202
|
-
for (const part of
|
|
308
|
+
for (const part of (req.headers.cookie || '').split(';')) {
|
|
203
309
|
const [k, ...v] = part.trim().split('=');
|
|
204
310
|
if (k) result[k.trim()] = decodeURIComponent(v.join('='));
|
|
205
311
|
}
|
|
206
312
|
return result;
|
|
207
313
|
}
|
|
208
314
|
|
|
209
|
-
// ─── User
|
|
315
|
+
// ─── User loading ──────────────────────────────────────────────────────────
|
|
210
316
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Load a user by PK. Returns null if not found or model not ready.
|
|
319
|
+
* Used by middleware on every admin request.
|
|
320
|
+
*/
|
|
321
|
+
async _loadUser(id) {
|
|
322
|
+
const M = this._resolveUserModel();
|
|
323
|
+
if (!M || !id) return null;
|
|
324
|
+
try {
|
|
325
|
+
return await M.find(id) || null;
|
|
326
|
+
} catch { return null; }
|
|
327
|
+
}
|
|
214
328
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Load a user by email. Returns null if not found.
|
|
331
|
+
* Used during login.
|
|
332
|
+
*/
|
|
333
|
+
async _loadUserByEmail(email) {
|
|
334
|
+
const M = this._resolveUserModel();
|
|
335
|
+
if (!M) return null;
|
|
336
|
+
try {
|
|
337
|
+
return await M.findBy('email', email) || null;
|
|
338
|
+
} catch { return null; }
|
|
339
|
+
}
|
|
221
340
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Resolve the User model.
|
|
343
|
+
* Priority: explicitly set via setUserModel() / config.model
|
|
344
|
+
* → app/models/User → built-in AuthUser
|
|
345
|
+
*/
|
|
346
|
+
_resolveUserModel() {
|
|
347
|
+
if (this._UserModel) return this._UserModel;
|
|
228
348
|
|
|
229
|
-
|
|
230
|
-
|
|
349
|
+
// Lazy fallback — allows AdminAuth to work even if boot order is unusual
|
|
350
|
+
try {
|
|
351
|
+
const nodePath = require('path');
|
|
352
|
+
const base = this._basePath || process.cwd();
|
|
353
|
+
const appUser = nodePath.join(base, 'app/models/User');
|
|
354
|
+
this._UserModel = require(appUser);
|
|
355
|
+
return this._UserModel;
|
|
356
|
+
} catch {}
|
|
231
357
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return bcrypt.compare(String(plain), hash);
|
|
237
|
-
}
|
|
238
|
-
// Plain text comparison — warn in development
|
|
239
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
240
|
-
process.stderr.write(
|
|
241
|
-
'[millas admin] Warning: using plain-text password. Use a bcrypt hash in production.\n'
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
return plain === hash;
|
|
358
|
+
try {
|
|
359
|
+
this._UserModel = require('../auth/AuthUser');
|
|
360
|
+
return this._UserModel;
|
|
361
|
+
} catch { return null; }
|
|
245
362
|
}
|
|
246
363
|
|
|
247
364
|
// ─── Rate limiting ────────────────────────────────────────────────────────
|
|
248
365
|
|
|
249
366
|
_checkRateLimit(ip) {
|
|
250
|
-
const entry = this._attempts
|
|
367
|
+
const entry = this._attempts.get(ip);
|
|
251
368
|
if (!entry) return;
|
|
252
|
-
|
|
253
369
|
if (entry.lockedUntil && Date.now() < entry.lockedUntil) {
|
|
254
370
|
const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
|
|
255
371
|
throw new Error(`Too many failed attempts. Try again in ${mins} minute${mins > 1 ? 's' : ''}.`);
|
|
256
372
|
}
|
|
257
|
-
|
|
258
373
|
if (entry.lockedUntil && Date.now() >= entry.lockedUntil) {
|
|
259
374
|
this._attempts.delete(ip);
|
|
260
375
|
}
|
|
261
376
|
}
|
|
262
377
|
|
|
263
378
|
_recordFailedAttempt(ip) {
|
|
264
|
-
const entry = this._attempts
|
|
379
|
+
const entry = this._attempts.get(ip) || { count: 0, lockedUntil: null };
|
|
265
380
|
entry.count++;
|
|
266
381
|
if (entry.count >= (this._config.maxAttempts || 5)) {
|
|
267
382
|
const mins = this._config.lockoutMinutes || 15;
|
|
268
383
|
entry.lockedUntil = Date.now() + mins * 60 * 1000;
|
|
269
384
|
}
|
|
270
|
-
this._attempts
|
|
385
|
+
this._attempts.set(ip, entry);
|
|
271
386
|
}
|
|
272
387
|
|
|
273
388
|
_clearAttempts(ip) {
|
|
274
|
-
this._attempts
|
|
389
|
+
this._attempts.delete(ip);
|
|
275
390
|
}
|
|
276
391
|
}
|
|
277
392
|
|
|
278
393
|
// Singleton
|
|
279
394
|
const adminAuth = new AdminAuth();
|
|
280
395
|
module.exports = adminAuth;
|
|
281
|
-
module.exports.AdminAuth = AdminAuth;
|
|
396
|
+
module.exports.AdminAuth = AdminAuth;
|