odac 1.0.0 → 1.1.0
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/.github/workflows/auto-pr-description.yml +3 -1
- package/CHANGELOG.md +127 -0
- package/README.md +39 -36
- package/bin/odac.js +1 -31
- package/client/odac.js +871 -994
- package/docs/backend/01-overview/03-development-server.md +7 -7
- package/docs/backend/02-structure/01-typical-project-layout.md +1 -0
- package/docs/backend/03-config/00-configuration-overview.md +9 -0
- package/docs/backend/03-config/01-database-connection.md +1 -1
- package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
- package/docs/backend/04-routing/09-websocket.md +29 -0
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
- package/docs/backend/05-controllers/03-controller-classes.md +27 -41
- package/docs/backend/05-forms/01-custom-forms.md +103 -95
- package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
- package/docs/backend/07-views/02-rendering-a-view.md +1 -1
- package/docs/backend/07-views/03-variables.md +5 -5
- package/docs/backend/07-views/04-request-data.md +1 -1
- package/docs/backend/07-views/08-backend-javascript.md +1 -1
- package/docs/backend/08-database/01-getting-started.md +100 -0
- package/docs/backend/08-database/02-basics.md +136 -0
- package/docs/backend/08-database/03-advanced.md +84 -0
- package/docs/backend/08-database/04-migrations.md +48 -0
- package/docs/backend/09-validation/01-the-validator-service.md +1 -0
- package/docs/backend/10-authentication/03-register.md +8 -1
- package/docs/backend/10-authentication/04-odac-register-forms.md +46 -46
- package/docs/backend/10-authentication/05-session-management.md +1 -1
- package/docs/backend/10-authentication/06-odac-login-forms.md +48 -48
- package/docs/backend/10-authentication/07-magic-links.md +134 -0
- package/docs/backend/11-mail/01-the-mail-service.md +118 -28
- package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
- package/docs/backend/13-utilities/01-odac-var.md +7 -7
- package/docs/backend/13-utilities/02-ipc.md +73 -0
- package/docs/frontend/01-overview/01-introduction.md +5 -1
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
- package/docs/index.json +16 -124
- package/eslint.config.mjs +5 -47
- package/package.json +9 -4
- package/src/Auth.js +362 -104
- package/src/Config.js +7 -2
- package/src/Database.js +188 -0
- package/src/Ipc.js +330 -0
- package/src/Mail.js +408 -37
- package/src/Odac.js +65 -9
- package/src/Request.js +70 -48
- package/src/Route/Cron.js +4 -1
- package/src/Route/Internal.js +214 -11
- package/src/Route/Middleware.js +7 -2
- package/src/Route.js +106 -26
- package/src/Server.js +80 -11
- package/src/Storage.js +165 -0
- package/src/Validator.js +94 -2
- package/src/View/Form.js +193 -17
- package/src/View.js +46 -1
- package/src/WebSocket.js +18 -3
- package/template/config.json +1 -1
- package/template/route/www.js +12 -10
- package/test/core/{Candy.test.js → Odac.test.js} +2 -2
- package/docs/backend/08-database/01-database-connection.md +0 -99
- package/docs/backend/08-database/02-using-mysql.md +0 -322
- package/src/Mysql.js +0 -575
package/src/Auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const nodeCrypto = require('crypto')
|
|
1
2
|
class Auth {
|
|
2
3
|
#request = null
|
|
3
4
|
#table = null
|
|
@@ -23,14 +24,26 @@ class Auth {
|
|
|
23
24
|
if (!this.#table) return false
|
|
24
25
|
if (where) {
|
|
25
26
|
if (!this.#validateInput(where)) return false
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
|
|
28
|
+
// Using new DB API
|
|
29
|
+
let query = Odac.DB[this.#table]
|
|
30
|
+
|
|
31
|
+
if (!query) {
|
|
32
|
+
console.error('Odac Auth Error: Database not configured.')
|
|
29
33
|
return false
|
|
30
34
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
|
|
36
|
+
// Knex build queries differently than previous builder
|
|
37
|
+
// Need to chain where clauses
|
|
38
|
+
for (let key in where) {
|
|
39
|
+
query = query.orWhere(key, where[key] instanceof Promise ? await where[key] : where[key])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Execute query
|
|
43
|
+
let get = await query
|
|
44
|
+
|
|
45
|
+
if (!get || get.length === 0) return false
|
|
46
|
+
|
|
34
47
|
let equal = false
|
|
35
48
|
for (var user of get) {
|
|
36
49
|
equal = Object.keys(where).length > 0
|
|
@@ -48,35 +61,51 @@ class Auth {
|
|
|
48
61
|
} else if (this.#user) {
|
|
49
62
|
return true
|
|
50
63
|
} else {
|
|
51
|
-
|
|
52
|
-
if (check_table.length == 0) return false
|
|
64
|
+
// Checking for token
|
|
53
65
|
let odac_x = this.#request.cookie('odac_x')
|
|
54
66
|
let odac_y = this.#request.cookie('odac_y')
|
|
55
67
|
let browser = this.#request.header('user-agent')
|
|
68
|
+
|
|
56
69
|
if (!odac_x || !odac_y || !browser) return false
|
|
70
|
+
|
|
57
71
|
const tokenTable = Odac.Config.auth.token || 'odac_auth'
|
|
58
72
|
const primaryKey = Odac.Config.auth.key || 'id'
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
|
|
74
|
+
// Code First Migration: Ensure token table exists and clean up old tokens
|
|
75
|
+
try {
|
|
76
|
+
await this.#ensureTokenTableV2(tokenTable)
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('Odac Auth Error: Failed to ensure token table exists:', e.message)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Query token
|
|
82
|
+
let sql_token = await Odac.DB[tokenTable].where('token_x', odac_x).where('browser', browser)
|
|
83
|
+
|
|
84
|
+
if (!sql_token || sql_token.length !== 1) return false
|
|
85
|
+
|
|
61
86
|
if (!Odac.Var(sql_token[0].token_y).hashCheck(odac_y)) return false
|
|
62
87
|
|
|
63
88
|
const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
|
|
64
89
|
const updateAge = Odac.Config.auth?.updateAge || 24 * 60 * 60 * 1000
|
|
65
90
|
const now = Date.now()
|
|
91
|
+
|
|
92
|
+
// Active comes as Date object usually from drivers
|
|
66
93
|
const lastActive = new Date(sql_token[0].active).getTime()
|
|
67
94
|
const inactiveAge = now - lastActive
|
|
68
95
|
|
|
69
96
|
if (inactiveAge > maxAge) {
|
|
70
|
-
await Odac.
|
|
97
|
+
await Odac.DB[tokenTable].where('id', sql_token[0].id).delete()
|
|
71
98
|
return false
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
this.#user = await Odac.
|
|
101
|
+
this.#user = await Odac.DB[this.#table].where(primaryKey, sql_token[0].user).first()
|
|
102
|
+
if (!this.#user) return false
|
|
75
103
|
|
|
76
104
|
if (inactiveAge > updateAge) {
|
|
77
|
-
|
|
105
|
+
// Use update instead of set for Knex
|
|
106
|
+
Odac.DB[tokenTable]
|
|
78
107
|
.where('id', sql_token[0].id)
|
|
79
|
-
.
|
|
108
|
+
.update({active: new Date()}) // knex uses .update
|
|
80
109
|
.catch(() => {})
|
|
81
110
|
}
|
|
82
111
|
|
|
@@ -88,51 +117,44 @@ class Auth {
|
|
|
88
117
|
this.#user = null
|
|
89
118
|
let user = await this.check(where)
|
|
90
119
|
if (!user) return false
|
|
120
|
+
|
|
91
121
|
if (!Odac.Config.auth) Odac.Config.auth = {}
|
|
92
122
|
let key = Odac.Config.auth.key || 'id'
|
|
93
123
|
let token = Odac.Config.auth.token || 'odac_auth'
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
let check_table = await Odac.Mysql.run('SHOW TABLES LIKE ?', [token])
|
|
97
|
-
if (check_table === false) {
|
|
98
|
-
console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
|
|
99
|
-
return false
|
|
100
|
-
}
|
|
101
|
-
if (check_table.length == 0)
|
|
102
|
-
await Odac.Mysql.run(
|
|
103
|
-
`CREATE TABLE ${safeTokenTable} (id INT NOT NULL AUTO_INCREMENT, user INT NOT NULL, token_x VARCHAR(255) NOT NULL, token_y VARCHAR(255) NOT NULL, browser VARCHAR(255) NOT NULL, ip VARCHAR(255) NOT NULL, \`date\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, \`active\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id))`
|
|
104
|
-
)
|
|
124
|
+
|
|
125
|
+
await this.#ensureTokenTableV2(token)
|
|
105
126
|
|
|
106
127
|
this.#cleanupExpiredTokens(token)
|
|
107
128
|
|
|
108
129
|
let token_y = Odac.Var(Math.random().toString() + Date.now().toString() + this.#request.id + this.#request.ip).md5()
|
|
130
|
+
|
|
109
131
|
let cookie = {
|
|
132
|
+
id: Odac.DB.nanoid(),
|
|
110
133
|
user: user[key],
|
|
111
134
|
token_x: Odac.Var(Math.random().toString() + Date.now().toString()).md5(),
|
|
112
135
|
token_y: Odac.Var(token_y).hash(),
|
|
113
136
|
browser: this.#request.header('user-agent'),
|
|
114
137
|
ip: this.#request.ip
|
|
115
138
|
}
|
|
139
|
+
|
|
116
140
|
this.#request.cookie('odac_x', cookie.token_x, {
|
|
117
141
|
httpOnly: true,
|
|
118
142
|
secure: true,
|
|
119
|
-
sameSite: '
|
|
143
|
+
sameSite: 'Lax'
|
|
120
144
|
})
|
|
121
|
-
this.#request.cookie('odac_y', token_y, {httpOnly: true, secure: true, sameSite: '
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
let sql = await mysqlTable.insert(cookie)
|
|
128
|
-
return sql !== false
|
|
145
|
+
this.#request.cookie('odac_y', token_y, {httpOnly: true, secure: true, sameSite: 'Lax'})
|
|
146
|
+
|
|
147
|
+
// Knex insert returns ids on some dbs, promise resolves to result
|
|
148
|
+
const result = await Odac.DB[token].insert(cookie)
|
|
149
|
+
return !!result
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
async #cleanupExpiredTokens(tokenTable) {
|
|
132
153
|
const maxAge = Odac.Config.auth?.maxAge || 30 * 24 * 60 * 60 * 1000
|
|
154
|
+
// Knex handles dates well, but better to pass JS Date object
|
|
133
155
|
const cutoffDate = new Date(Date.now() - maxAge)
|
|
134
156
|
|
|
135
|
-
Odac.
|
|
157
|
+
Odac.DB[tokenTable]
|
|
136
158
|
.where('active', '<', cutoffDate)
|
|
137
159
|
.delete()
|
|
138
160
|
.catch(() => {})
|
|
@@ -148,13 +170,12 @@ class Auth {
|
|
|
148
170
|
const passwordField = options.passwordField || 'password'
|
|
149
171
|
const uniqueFields = options.uniqueFields || ['email']
|
|
150
172
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await this.#createUserTable(this.#table, primaryKey, passwordField, uniqueFields, data)
|
|
173
|
+
try {
|
|
174
|
+
await this.#ensureUserTableV2(this.#table, primaryKey, passwordField, uniqueFields, data)
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// If DB not configured or connection failed
|
|
177
|
+
console.error('Odac Auth Error:', e.message)
|
|
178
|
+
return {success: false, error: 'Database connection failed'}
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
if (!data || typeof data !== 'object') {
|
|
@@ -165,41 +186,65 @@ class Auth {
|
|
|
165
186
|
data[passwordField] = Odac.Var(data[passwordField]).hash()
|
|
166
187
|
}
|
|
167
188
|
|
|
189
|
+
// Check unique fields
|
|
168
190
|
for (const field of uniqueFields) {
|
|
169
191
|
if (data[field]) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
192
|
+
try {
|
|
193
|
+
const existing = await Odac.DB[this.#table].where(field, data[field]).first()
|
|
194
|
+
if (existing) {
|
|
195
|
+
return {success: false, error: `${field} already exists`, field}
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error('Odac Auth Error checking unique:', e.message)
|
|
199
|
+
return {success: false, error: 'A database error occurred during registration.'}
|
|
174
200
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Auto-detect ID strategy (NanoID vs Auto-Increment)
|
|
205
|
+
let shouldGenerateId = true
|
|
206
|
+
|
|
207
|
+
// 1. Check User Config Preference
|
|
208
|
+
if (Odac.Config.auth.idType === 'int' || Odac.Config.auth.idType === 'auto') shouldGenerateId = false
|
|
209
|
+
else if (Odac.Config.auth.idType === 'string' || Odac.Config.auth.idType === 'nanoid') shouldGenerateId = true
|
|
210
|
+
else {
|
|
211
|
+
// 2. Detect from Database Schema (and Cache it)
|
|
212
|
+
if (!Odac.Config.auth._viewedPkType) {
|
|
213
|
+
try {
|
|
214
|
+
// Determine column type of primary key
|
|
215
|
+
const colInfo = await Odac.DB[this.#table].columnInfo(primaryKey)
|
|
216
|
+
const type = colInfo?.type ? colInfo.type.toLowerCase() : 'string'
|
|
217
|
+
|
|
218
|
+
// Common integer types in various SQL dialects
|
|
219
|
+
if (type.includes('int') || type.includes('serial') || type.includes('number')) {
|
|
220
|
+
Odac.Config.auth._viewedPkType = 'int'
|
|
221
|
+
} else {
|
|
222
|
+
Odac.Config.auth._viewedPkType = 'string'
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// If table doesn't exist yet or error, default to string (NanoID) as per our new standard
|
|
226
|
+
Odac.Config.auth._viewedPkType = 'string'
|
|
178
227
|
}
|
|
179
228
|
}
|
|
229
|
+
|
|
230
|
+
if (Odac.Config.auth._viewedPkType === 'int') shouldGenerateId = false
|
|
180
231
|
}
|
|
181
232
|
|
|
182
233
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
console.error('Odac Auth Error: MySQL connection not configured. Please add database configuration to your config.json')
|
|
186
|
-
return {success: false, error: 'Database connection not configured'}
|
|
187
|
-
}
|
|
188
|
-
const insertResult = await mysqlTable.insert(data)
|
|
189
|
-
if (insertResult === false) {
|
|
190
|
-
console.error('Odac Auth Error: Failed to insert user into database - query failed')
|
|
191
|
-
console.error('Data attempted to insert:', {...data, [passwordField]: '[REDACTED]'})
|
|
192
|
-
return {success: false, error: 'Failed to create user'}
|
|
234
|
+
if (shouldGenerateId && !data[primaryKey]) {
|
|
235
|
+
data[primaryKey] = Odac.DB.nanoid()
|
|
193
236
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
237
|
+
|
|
238
|
+
await Odac.DB[this.#table].insert(data)
|
|
239
|
+
|
|
240
|
+
let userId = data[primaryKey]
|
|
241
|
+
|
|
242
|
+
if (!userId) {
|
|
243
|
+
console.error('Odac Auth Error: Could not determine new user ID')
|
|
198
244
|
return {success: false, error: 'Failed to create user'}
|
|
199
245
|
}
|
|
200
246
|
|
|
201
|
-
const
|
|
202
|
-
const newUser = await Odac.Mysql.table(this.#table).where(primaryKey, userId).first()
|
|
247
|
+
const newUser = await Odac.DB[this.#table].where(primaryKey, userId).first()
|
|
203
248
|
|
|
204
249
|
if (!newUser) {
|
|
205
250
|
return {success: false, error: 'User created but could not be retrieved'}
|
|
@@ -221,7 +266,6 @@ class Auth {
|
|
|
221
266
|
} catch (error) {
|
|
222
267
|
console.error('Odac Auth Error: Registration failed with exception')
|
|
223
268
|
console.error('Error:', error.message)
|
|
224
|
-
console.error('Stack:', error.stack)
|
|
225
269
|
return {success: false, error: error.message || 'Registration failed'}
|
|
226
270
|
}
|
|
227
271
|
}
|
|
@@ -235,10 +279,7 @@ class Auth {
|
|
|
235
279
|
const browser = this.#request.header('user-agent')
|
|
236
280
|
|
|
237
281
|
if (odacX && browser) {
|
|
238
|
-
|
|
239
|
-
if (mysqlTable) {
|
|
240
|
-
await mysqlTable.where(['token_x', odacX], ['browser', browser]).delete()
|
|
241
|
-
}
|
|
282
|
+
await Odac.DB[token].where('token_x', odacX).where('browser', browser).delete()
|
|
242
283
|
}
|
|
243
284
|
|
|
244
285
|
this.#request.cookie('odac_x', '', {maxAge: -1})
|
|
@@ -248,55 +289,272 @@ class Auth {
|
|
|
248
289
|
return true
|
|
249
290
|
}
|
|
250
291
|
|
|
251
|
-
|
|
252
|
-
const mysql = require('mysql2')
|
|
253
|
-
const columns = []
|
|
292
|
+
// --- MAGIC LINK START ---
|
|
254
293
|
|
|
255
|
-
|
|
256
|
-
|
|
294
|
+
async magic(email, options = {}) {
|
|
295
|
+
if (!Odac.Config.auth) Odac.Config.auth = {}
|
|
296
|
+
this.#table = Odac.Config.auth.table || 'users'
|
|
297
|
+
const magicTable = Odac.Config.auth.magicTable || 'odac_magic'
|
|
257
298
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
299
|
+
// Ensure magic table exists
|
|
300
|
+
try {
|
|
301
|
+
await this.#ensureMagicLinkTable(magicTable)
|
|
302
|
+
} catch (e) {
|
|
303
|
+
console.error('Failed to ensure magic link table exists:', e)
|
|
304
|
+
// Consider returning an error here to prevent further execution.
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Rate limiting: Check recent requests from this IP and email
|
|
308
|
+
// Magic link requires only email input, so rate limits should be very strict
|
|
309
|
+
const rateLimitWindow = Odac.Config.auth?.magicLinkRateLimit || 60 * 60 * 1000 // 1 hour default
|
|
310
|
+
const maxAttempts = Odac.Config.auth?.magicLinkMaxAttempts || 2 // Per email - very strict
|
|
311
|
+
const maxAttemptsPerIP = Odac.Config.auth?.magicLinkMaxAttemptsPerIP || 5 // Per IP
|
|
312
|
+
const sessionCooldown = Odac.Config.auth?.magicLinkSessionCooldown || 30 * 1000 // 30 seconds default
|
|
313
|
+
|
|
314
|
+
// 1. Session Rate Limit (Fastest, no DB access)
|
|
315
|
+
const lastRequestTime = this.#request.session('magic_last_request')
|
|
316
|
+
if (lastRequestTime && Date.now() - lastRequestTime < sessionCooldown) {
|
|
317
|
+
const remaining = Math.ceil((sessionCooldown - (Date.now() - lastRequestTime)) / 1000)
|
|
318
|
+
return {success: false, error: `Please wait ${remaining} seconds before requesting another link.`}
|
|
319
|
+
}
|
|
320
|
+
this.#request.session('magic_last_request', Date.now())
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
// 2. Database Rate Limits
|
|
324
|
+
// Check email-based rate limit
|
|
325
|
+
const recentEmailRequests = await Odac.DB[magicTable]
|
|
326
|
+
.where('email', email)
|
|
327
|
+
.where('created_at', '>', new Date(Date.now() - rateLimitWindow))
|
|
328
|
+
|
|
329
|
+
if (recentEmailRequests && recentEmailRequests.length >= maxAttempts) {
|
|
330
|
+
return {success: false, error: 'Too many login attempts. Please wait a while before trying again.'}
|
|
262
331
|
}
|
|
332
|
+
|
|
333
|
+
// Check IP-based rate limit (prevents mass enumeration attacks)
|
|
334
|
+
const clientIP = this.#request.ip
|
|
335
|
+
const recentIPRequests = await Odac.DB[magicTable]
|
|
336
|
+
.where('ip', clientIP)
|
|
337
|
+
.where('created_at', '>', new Date(Date.now() - rateLimitWindow))
|
|
338
|
+
|
|
339
|
+
if (recentIPRequests && recentIPRequests.length >= maxAttemptsPerIP) {
|
|
340
|
+
return {success: false, error: 'Too many requests from this IP. Please wait a while.'}
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore rate limit check errors, proceed with request
|
|
263
344
|
}
|
|
264
345
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
346
|
+
// Cleanup: Remove expired tokens periodically
|
|
347
|
+
this.#cleanupExpiredMagicLinks(magicTable)
|
|
348
|
+
|
|
349
|
+
// 1. Check if user exists.
|
|
350
|
+
// We proceed regardless of whether the user exists or not.
|
|
351
|
+
// If they exist, it's a login. If not, it will accept the link and Auto-Register them (Passwordless Signup).
|
|
352
|
+
// let user = null
|
|
353
|
+
try {
|
|
354
|
+
// Check if user exists (logic preserved but unused 'user' variable issue fixed)
|
|
355
|
+
const existingUser = await Odac.DB[this.#table].where('email', email).first()
|
|
356
|
+
if (existingUser) {
|
|
357
|
+
/* user exists */
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
// Ignore table not found error, treat as user not found
|
|
361
|
+
if (e.code !== '42P01' && !e.message.includes('no such table')) {
|
|
362
|
+
throw e
|
|
363
|
+
}
|
|
268
364
|
}
|
|
269
365
|
|
|
270
|
-
for
|
|
271
|
-
|
|
366
|
+
// If user doesn't exist, we still proceed to send the link to allow for "Sign Up via Magic Link" (Passwordless Signup)
|
|
367
|
+
// The user will be created upon verification.
|
|
272
368
|
|
|
273
|
-
|
|
274
|
-
|
|
369
|
+
// 2. Generate secure token
|
|
370
|
+
const tokenRaw = nodeCrypto.randomBytes(32).toString('hex')
|
|
371
|
+
const tokenHash = Odac.Var(tokenRaw).hash() // Hash it for DB storage
|
|
275
372
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
373
|
+
// 3. Save to DB
|
|
374
|
+
await Odac.DB[magicTable].insert({
|
|
375
|
+
email: email,
|
|
376
|
+
token_hash: tokenHash,
|
|
377
|
+
ip: this.#request.ip,
|
|
378
|
+
browser: this.#request.header('user-agent'),
|
|
379
|
+
expires_at: new Date(Date.now() + 15 * 60 * 1000) // 15 mins
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// 4. Send Email
|
|
383
|
+
let link = `${(this.#request.ssl ? 'https://' : 'http://') + this.#request.host}/_odac/magic-verify?token=${tokenRaw}&email=${encodeURIComponent(email)}`
|
|
384
|
+
if (options.redirect) link += `&redirect_url=${encodeURIComponent(options.redirect)}`
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
let mail = Odac.Mail(options.template || 'auth/magic-link')
|
|
388
|
+
.to(email)
|
|
389
|
+
.subject(options.subject || 'Login to our site')
|
|
390
|
+
|
|
391
|
+
if (options.from) {
|
|
392
|
+
if (typeof options.from === 'object') mail.from(options.from.email, options.from.name)
|
|
393
|
+
else mail.from(options.from)
|
|
286
394
|
}
|
|
287
395
|
|
|
288
|
-
|
|
289
|
-
|
|
396
|
+
await mail.send({
|
|
397
|
+
link: link,
|
|
398
|
+
magic_link: link,
|
|
399
|
+
network: this.#request.host,
|
|
400
|
+
ip: this.#request.ip
|
|
401
|
+
})
|
|
402
|
+
} catch (e) {
|
|
403
|
+
console.error('Magic Link Email Error:', e)
|
|
404
|
+
return {success: false, error: 'Failed to send email'}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {success: true, message: 'Magic link sent!'}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async verifyMagicLink(tokenRaw, email) {
|
|
411
|
+
if (!tokenRaw || !email) {
|
|
412
|
+
return {success: false, error: 'Invalid link'}
|
|
290
413
|
}
|
|
291
414
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
415
|
+
const magicTable = Odac.Config.auth?.magicTable || 'odac_magic'
|
|
416
|
+
this.#table = Odac.Config.auth?.table || 'users'
|
|
417
|
+
const primaryKey = Odac.Config.auth?.key || 'id'
|
|
295
418
|
|
|
296
|
-
|
|
297
|
-
const
|
|
419
|
+
// 1. Find potential tokens for this email
|
|
420
|
+
const records = await Odac.DB[magicTable].where('email', email).where('expires_at', '>', new Date())
|
|
421
|
+
|
|
422
|
+
if (!records || records.length === 0) {
|
|
423
|
+
return {success: false, error: 'Link expired or invalid'}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 2. Find the matching token (verify hash)
|
|
427
|
+
let validRecord = null
|
|
428
|
+
// Iterate through all records without an early exit to mitigate timing attacks.
|
|
429
|
+
for (const record of records) {
|
|
430
|
+
if (Odac.Var(record.token_hash).hashCheck(tokenRaw)) {
|
|
431
|
+
validRecord = record
|
|
432
|
+
}
|
|
433
|
+
}
|
|
298
434
|
|
|
299
|
-
|
|
435
|
+
if (!validRecord) {
|
|
436
|
+
return {success: false, error: 'Invalid token'}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 3. Consume all tokens for this email to prevent reuse of other valid links.
|
|
440
|
+
await Odac.DB[magicTable].where('email', email).delete()
|
|
441
|
+
|
|
442
|
+
// 4. Log in user (or Register if new)
|
|
443
|
+
let user = await Odac.DB[this.#table].where('email', email).first()
|
|
444
|
+
|
|
445
|
+
if (!user) {
|
|
446
|
+
// Auto-Register the user
|
|
447
|
+
|
|
448
|
+
const passwordField = Odac.Config.auth?.passwordField || 'password'
|
|
449
|
+
// Optimization: If explicitly configured as passwordless, skip password generation overhead
|
|
450
|
+
const isPasswordless = Odac.Config.auth?.passwordless === true
|
|
451
|
+
|
|
452
|
+
const registerData = {
|
|
453
|
+
email: email
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!isPasswordless) {
|
|
457
|
+
// Generate a random high-entropy password since they are using passwordless auth but DB might require password
|
|
458
|
+
registerData[passwordField] = nodeCrypto.randomBytes(32).toString('hex')
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let regResult = await this.register(registerData)
|
|
462
|
+
|
|
463
|
+
// Fallback: If we tried to be secure (sent password) but DB failed because column doesn't exist, retry without password
|
|
464
|
+
if (
|
|
465
|
+
!isPasswordless &&
|
|
466
|
+
!regResult.success &&
|
|
467
|
+
regResult.error &&
|
|
468
|
+
(regResult.error.includes(`column "${passwordField}"`) || regResult.error.includes(`Unknown column '${passwordField}'`)) &&
|
|
469
|
+
(regResult.error.includes('does not exist') || regResult.error.includes('field list'))
|
|
470
|
+
) {
|
|
471
|
+
regResult = await this.register({
|
|
472
|
+
email: email
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!regResult.success) {
|
|
477
|
+
return {success: false, error: 'Registration failed: ' + regResult.error}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
user = regResult.user
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Login logic similar to login()
|
|
484
|
+
const loginData = {}
|
|
485
|
+
loginData[primaryKey] = user[primaryKey]
|
|
486
|
+
await this.login(loginData)
|
|
487
|
+
|
|
488
|
+
return {success: true, user: user}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async #ensureMagicLinkTable(tableName) {
|
|
492
|
+
await Odac.DB[tableName].schema(t => {
|
|
493
|
+
t.increments('id')
|
|
494
|
+
t.string('email').notNullable().index()
|
|
495
|
+
t.string('token_hash').notNullable()
|
|
496
|
+
t.string('ip')
|
|
497
|
+
t.string('browser')
|
|
498
|
+
t.timestamp('created_at').defaultTo(Odac.DB.fn.now())
|
|
499
|
+
t.timestamp('expires_at')
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#cleanupExpiredMagicLinks(tableName) {
|
|
504
|
+
// Run cleanup asynchronously without awaiting (fire and forget)
|
|
505
|
+
Odac.DB[tableName]
|
|
506
|
+
.where('expires_at', '<', new Date())
|
|
507
|
+
.delete()
|
|
508
|
+
.catch(() => {}) // Silently ignore cleanup errors
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// --- MAGIC LINK END ---
|
|
512
|
+
|
|
513
|
+
// --- MIGRATION HELPERS (Code-First) ---
|
|
514
|
+
|
|
515
|
+
async #ensureTokenTableV2(tableName) {
|
|
516
|
+
// Using .schema helper
|
|
517
|
+
await Odac.DB[tableName].schema(t => {
|
|
518
|
+
t.string('id', 21).primary()
|
|
519
|
+
t.string('user', 21).notNullable()
|
|
520
|
+
t.string('token_x').notNullable()
|
|
521
|
+
t.string('token_y').notNullable()
|
|
522
|
+
t.string('browser').notNullable()
|
|
523
|
+
t.string('ip').notNullable()
|
|
524
|
+
t.timestamp('date').defaultTo(Odac.DB.fn.now())
|
|
525
|
+
t.timestamp('active').defaultTo(Odac.DB.fn.now())
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async #ensureUserTableV2(tableName, primaryKey, passwordField, uniqueFields, sampleData) {
|
|
530
|
+
await Odac.DB[tableName].schema(t => {
|
|
531
|
+
t.string(primaryKey, 21).primary()
|
|
532
|
+
|
|
533
|
+
for (const field of uniqueFields) {
|
|
534
|
+
if (field !== primaryKey) t.string(field).notNullable().unique()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!uniqueFields.includes(passwordField) && passwordField !== primaryKey) {
|
|
538
|
+
t.string(passwordField).notNullable()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Heuristic type guessing from sampleData
|
|
542
|
+
for (const key in sampleData) {
|
|
543
|
+
if (key === primaryKey || uniqueFields.includes(key) || key === passwordField) continue
|
|
544
|
+
|
|
545
|
+
const val = sampleData[key]
|
|
546
|
+
if (typeof val === 'number') {
|
|
547
|
+
if (Number.isInteger(val)) t.integer(key)
|
|
548
|
+
else t.float(key)
|
|
549
|
+
} else if (typeof val === 'boolean') {
|
|
550
|
+
t.boolean(key)
|
|
551
|
+
} else {
|
|
552
|
+
t.string(key)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
t.timestamps(true, true) // created_at, updated_at
|
|
557
|
+
})
|
|
300
558
|
}
|
|
301
559
|
|
|
302
560
|
user(col) {
|
package/src/Config.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('os')
|
|
|
5
5
|
module.exports = {
|
|
6
6
|
auth: {
|
|
7
7
|
key: 'id',
|
|
8
|
-
token: '
|
|
8
|
+
token: 'odac_auth'
|
|
9
9
|
},
|
|
10
10
|
request: {
|
|
11
11
|
timeout: 10000
|
|
@@ -18,6 +18,11 @@ module.exports = {
|
|
|
18
18
|
auto: true,
|
|
19
19
|
maxResources: 5
|
|
20
20
|
},
|
|
21
|
+
ipc: {
|
|
22
|
+
driver: 'memory',
|
|
23
|
+
redis: 'default'
|
|
24
|
+
},
|
|
25
|
+
debug: false,
|
|
21
26
|
|
|
22
27
|
init: function () {
|
|
23
28
|
try {
|
|
@@ -44,7 +49,7 @@ module.exports = {
|
|
|
44
49
|
return obj.replace(/\$\{(\w+)\}/g, (_, key) => {
|
|
45
50
|
// Special variables
|
|
46
51
|
if (key === 'odac') {
|
|
47
|
-
return __dirname.replace(/\/
|
|
52
|
+
return __dirname.replace(/\/src$/, '/client')
|
|
48
53
|
}
|
|
49
54
|
// Environment variables
|
|
50
55
|
return process.env[key] || ''
|