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