@metaadri/secureauth 1.2.1 → 1.2.3
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/detector.js +8 -2
- package/src/generator.js +64 -4
- package/src/index.js +22 -7
- package/src/installer.js +1 -1
- package/src/patcher.js +409 -0
- package/src/questions.js +16 -0
- package/src/scaffolder.js +88 -0
package/package.json
CHANGED
package/src/detector.js
CHANGED
|
@@ -3,11 +3,13 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
const REQUIRED_DIRS = ['controllers', 'routes', 'middleware', 'models', 'utils', 'database'];
|
|
5
5
|
const SECUREAUTH_DEPS = [
|
|
6
|
-
'express', '
|
|
6
|
+
'express', 'jsonwebtoken', 'helmet',
|
|
7
7
|
'express-rate-limit', 'express-slow-down', 'speakeasy',
|
|
8
8
|
'qrcode', 'dotenv', 'cors', 'uuid'
|
|
9
9
|
];
|
|
10
10
|
|
|
11
|
+
const BCRYPT_ALIASES = ['bcryptjs', 'bcrypt'];
|
|
12
|
+
|
|
11
13
|
function detect(dir) {
|
|
12
14
|
const report = {
|
|
13
15
|
hasPackageJson: false,
|
|
@@ -49,7 +51,11 @@ function detect(dir) {
|
|
|
49
51
|
} catch {}
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
const hasBcrypt = BCRYPT_ALIASES.some(d => d in allDeps);
|
|
55
|
+
report.missingDeps = SECUREAUTH_DEPS.filter(d => {
|
|
56
|
+
if (d === 'bcryptjs') return !hasBcrypt;
|
|
57
|
+
return !(d in allDeps);
|
|
58
|
+
});
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
const existingDirs = [];
|
package/src/generator.js
CHANGED
|
@@ -60,18 +60,78 @@ function buildEnvContent(answers) {
|
|
|
60
60
|
return lines.join('\n');
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function mergeEnvContent(existingContent, newContent) {
|
|
64
|
+
const existingLines = existingContent.split('\n');
|
|
65
|
+
const existingKeys = new Set();
|
|
66
|
+
let existingDbUrl = null;
|
|
67
|
+
|
|
68
|
+
for (const line of existingLines) {
|
|
69
|
+
const eqIdx = line.indexOf('=');
|
|
70
|
+
if (eqIdx !== -1) {
|
|
71
|
+
const key = line.slice(0, eqIdx).trim();
|
|
72
|
+
existingKeys.add(key);
|
|
73
|
+
if (key === 'DATABASE_URL') {
|
|
74
|
+
existingDbUrl = line.slice(eqIdx + 1).trim();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const newLines = newContent.split('\n');
|
|
80
|
+
const merged = [];
|
|
81
|
+
|
|
82
|
+
if (existingDbUrl) {
|
|
83
|
+
for (const line of newLines) {
|
|
84
|
+
if (line.startsWith('DATABASE_URL=')) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
merged.push(line);
|
|
88
|
+
}
|
|
89
|
+
const dbSectionStart = merged.findIndex(l => l.includes('# Database'));
|
|
90
|
+
if (dbSectionStart !== -1) {
|
|
91
|
+
merged.splice(dbSectionStart, 0, `DATABASE_URL=${existingDbUrl}`);
|
|
92
|
+
} else {
|
|
93
|
+
merged.push('');
|
|
94
|
+
merged.push('# Database Configuration (preserved from existing .env)');
|
|
95
|
+
merged.push(`DATABASE_URL=${existingDbUrl}`);
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
merged.push(...newLines);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const key of existingKeys) {
|
|
102
|
+
if (key === 'DATABASE_URL') continue;
|
|
103
|
+
const existingLine = existingLines.find(l => l.startsWith(key + '='));
|
|
104
|
+
if (existingLine) {
|
|
105
|
+
const newIdx = merged.findIndex(l => l.startsWith(key + '='));
|
|
106
|
+
if (newIdx !== -1) {
|
|
107
|
+
merged[newIdx] = existingLine;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return merged.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
function writeEnvFile(targetDir, answers) {
|
|
64
116
|
const fs = require('fs');
|
|
65
117
|
const path = require('path');
|
|
66
118
|
const envPath = path.join(targetDir, '.env');
|
|
67
119
|
|
|
120
|
+
const newContent = buildEnvContent(answers);
|
|
121
|
+
|
|
68
122
|
if (fs.existsSync(envPath)) {
|
|
69
|
-
|
|
70
|
-
|
|
123
|
+
const existingContent = fs.readFileSync(envPath, 'utf-8');
|
|
124
|
+
const merged = mergeEnvContent(existingContent, newContent);
|
|
125
|
+
if (merged !== existingContent) {
|
|
126
|
+
fs.writeFileSync(envPath, merged, 'utf-8');
|
|
127
|
+
logger.success('.env updated with SecureAuth settings (preserved existing values)');
|
|
128
|
+
} else {
|
|
129
|
+
logger.warn('.env already exists — settings are up to date');
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
71
132
|
}
|
|
72
133
|
|
|
73
|
-
|
|
74
|
-
fs.writeFileSync(envPath, content, 'utf-8');
|
|
134
|
+
fs.writeFileSync(envPath, newContent, 'utf-8');
|
|
75
135
|
logger.success('.env created with secure defaults');
|
|
76
136
|
return true;
|
|
77
137
|
}
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const { printReport } = require('./reporter');
|
|
|
6
6
|
const { install } = require('./installer');
|
|
7
7
|
const { writeEnvFile } = require('./generator');
|
|
8
8
|
const { scaffold } = require('./scaffolder');
|
|
9
|
+
const { patchExisting } = require('./patcher');
|
|
9
10
|
const {
|
|
10
11
|
askProjectType,
|
|
11
12
|
askProjectName,
|
|
@@ -13,6 +14,7 @@ const {
|
|
|
13
14
|
askPort,
|
|
14
15
|
askJwtSecret,
|
|
15
16
|
askFeatures,
|
|
17
|
+
askInstallMode,
|
|
16
18
|
askConfirm
|
|
17
19
|
} = require('./questions');
|
|
18
20
|
|
|
@@ -101,6 +103,12 @@ async function init(defaults) {
|
|
|
101
103
|
logger.info(logger.dim('Not sure which features to pick? Visit https://secureauth106293.netlify.app/ for guidance.'));
|
|
102
104
|
answers.features = await askFeatures(defaults);
|
|
103
105
|
|
|
106
|
+
if (projectType === 'existing') {
|
|
107
|
+
answers.installMode = await askInstallMode(defaults);
|
|
108
|
+
} else {
|
|
109
|
+
answers.installMode = 'scaffold';
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
logger.info('');
|
|
105
113
|
const confirmed = await askConfirm(defaults);
|
|
106
114
|
if (!confirmed) {
|
|
@@ -113,16 +121,23 @@ async function init(defaults) {
|
|
|
113
121
|
logger.info(`Database: ${answers.database}`);
|
|
114
122
|
logger.info(`Port: ${answers.port}`);
|
|
115
123
|
logger.info(`Features: ${answers.features.join(', ') || 'none'}`);
|
|
124
|
+
if (projectType === 'existing') {
|
|
125
|
+
logger.info(`Mode: ${answers.installMode === 'patch' ? 'Patch existing login route' : 'Add alongside existing auth'}`);
|
|
126
|
+
}
|
|
116
127
|
|
|
117
128
|
logger.info('');
|
|
118
129
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
if (answers.installMode === 'patch') {
|
|
131
|
+
await install(targetDir, report.missingDeps, answers);
|
|
132
|
+
patchExisting(targetDir, answers);
|
|
133
|
+
writeEnvFile(targetDir, answers);
|
|
134
|
+
printSuccess(answers, 0);
|
|
135
|
+
} else {
|
|
136
|
+
await install(targetDir, report.missingDeps, answers);
|
|
137
|
+
const filesCreated = scaffold(targetDir, answers);
|
|
138
|
+
writeEnvFile(targetDir, answers);
|
|
139
|
+
printSuccess(answers, filesCreated);
|
|
140
|
+
}
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
module.exports = { init };
|
package/src/installer.js
CHANGED
package/src/patcher.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { logger } = require('./logger');
|
|
4
|
+
|
|
5
|
+
const ENTRY_CANDIDATES = ['server.js', 'app.js', 'index.js'];
|
|
6
|
+
const NESTED_ENTRIES = [
|
|
7
|
+
['backend', 'server.js'],
|
|
8
|
+
['backend', 'app.js'],
|
|
9
|
+
['backend', 'index.js'],
|
|
10
|
+
['src', 'server.js'],
|
|
11
|
+
['src', 'app.js'],
|
|
12
|
+
['src', 'index.js']
|
|
13
|
+
];
|
|
14
|
+
const AUTH_ROUTE_PATTERNS = [
|
|
15
|
+
['routes', 'auth.js'],
|
|
16
|
+
['routes', 'authRoutes.js'],
|
|
17
|
+
['backend', 'routes', 'auth.js'],
|
|
18
|
+
['backend', 'routes', 'authRoutes.js'],
|
|
19
|
+
['src', 'routes', 'auth.js'],
|
|
20
|
+
['src', 'routes', 'authRoutes.js']
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function findEntryPoint(targetDir) {
|
|
24
|
+
for (const name of ENTRY_CANDIDATES) {
|
|
25
|
+
const p = path.join(targetDir, name);
|
|
26
|
+
if (fs.existsSync(p)) return p;
|
|
27
|
+
}
|
|
28
|
+
for (const parts of NESTED_ENTRIES) {
|
|
29
|
+
const p = path.join(targetDir, ...parts);
|
|
30
|
+
if (fs.existsSync(p)) return p;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findAuthRouteFile(targetDir) {
|
|
36
|
+
const entry = findEntryPoint(targetDir);
|
|
37
|
+
if (entry) {
|
|
38
|
+
const content = fs.readFileSync(entry, 'utf-8');
|
|
39
|
+
const regex = /require\(['"]\.\/?([^'"]*(?:auth|login)[^'"]*)['"]\)/i;
|
|
40
|
+
const m = content.match(regex);
|
|
41
|
+
if (m) {
|
|
42
|
+
let routePath = m[1].replace(/\.js$/, '');
|
|
43
|
+
const base = path.dirname(entry);
|
|
44
|
+
for (const ext of ['', '.js']) {
|
|
45
|
+
const full = path.resolve(base, routePath + ext);
|
|
46
|
+
if (fs.existsSync(full)) return full;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const parts of AUTH_ROUTE_PATTERNS) {
|
|
51
|
+
const p = path.join(targetDir, ...parts);
|
|
52
|
+
if (fs.existsSync(p)) return p;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function detectDatabaseType(targetDir) {
|
|
58
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
59
|
+
if (fs.existsSync(pkgPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
62
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
63
|
+
if ('pg' in allDeps || '@neondatabase/serverless' in allDeps) return 'postgres';
|
|
64
|
+
if ('better-sqlite3' in allDeps || 'sql.js' in allDeps) return 'sqlite';
|
|
65
|
+
if ('mysql2' in allDeps) return 'mysql';
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
const entry = findEntryPoint(targetDir);
|
|
69
|
+
if (entry) {
|
|
70
|
+
try {
|
|
71
|
+
const content = fs.readFileSync(entry, 'utf-8');
|
|
72
|
+
if (/require\(['"](pg|@neondatabase\/serverless)['"]\)/.test(content)) return 'postgres';
|
|
73
|
+
if (/require\(['"]better-sqlite3['"]\)/.test(content)) return 'sqlite';
|
|
74
|
+
if (/require\(['"]mysql2['"]\)/.test(content)) return 'mysql';
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
const authFile = findAuthRouteFile(targetDir);
|
|
78
|
+
if (authFile) {
|
|
79
|
+
try {
|
|
80
|
+
const content = fs.readFileSync(authFile, 'utf-8');
|
|
81
|
+
if (/\$\d/.test(content)) return 'postgres';
|
|
82
|
+
if (/\?\s*\]/.test(content) && /better-sqlite3/.test(content)) return 'sqlite';
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
return 'sqlite';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isAlreadyPatched(content) {
|
|
89
|
+
return /checkAccountLockout/.test(content) ||
|
|
90
|
+
/MAX_FAILED_ATTEMPTS/.test(content) ||
|
|
91
|
+
/failed_login_attempts/.test(content) ||
|
|
92
|
+
/LOCKOUT_DURATION_MINUTES/.test(content);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function detectDbVariable(content) {
|
|
96
|
+
const poolMatch = content.match(/(?:const|let|var)\s+(pool|db|client|query)\s*=\s*(?:new\s+(?:Pool|Client|Database)|require\()/i);
|
|
97
|
+
if (poolMatch) return poolMatch[1];
|
|
98
|
+
const exportMatch = content.match(/(?:const|let|var)\s+(\w+)\s*=\s*require\(['"]\.\.?\/[^'"]*db[^'"]*['"]\)/i);
|
|
99
|
+
if (exportMatch) return exportMatch[1];
|
|
100
|
+
return 'pool';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectParamStyle(content) {
|
|
104
|
+
if (/\$\d/.test(content)) return 'postgres';
|
|
105
|
+
return 'sqlite';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function generateLockoutEnvBlock() {
|
|
109
|
+
return `
|
|
110
|
+
const MAX_FAILED_ATTEMPTS = parseInt(process.env.MAX_FAILED_ATTEMPTS) || 5;
|
|
111
|
+
const LOCKOUT_DURATION_MINUTES = parseInt(process.env.LOCKOUT_DURATION_MINUTES) || 30;
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function generateHelperBlock() {
|
|
116
|
+
return `
|
|
117
|
+
|
|
118
|
+
async function checkAccountLockout(user) {
|
|
119
|
+
if (user.locked_until) {
|
|
120
|
+
const lockedUntil = new Date(user.locked_until);
|
|
121
|
+
if (lockedUntil > new Date()) {
|
|
122
|
+
const minutesLeft = Math.ceil((lockedUntil - new Date()) / 60000);
|
|
123
|
+
const err = new Error(\`Account locked due to too many failed attempts. Try again in \${minutesLeft} minute(s).\`);
|
|
124
|
+
err.statusCode = 423;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function generateLockoutCheckBlock() {
|
|
133
|
+
return `
|
|
134
|
+
try {
|
|
135
|
+
await checkAccountLockout(user);
|
|
136
|
+
} catch (lockErr) {
|
|
137
|
+
return res.status(lockErr.statusCode || 423).json({
|
|
138
|
+
error: 'Account locked',
|
|
139
|
+
message: lockErr.message
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function generateIncrementBlock(dbVar, paramStyle) {
|
|
146
|
+
if (paramStyle === 'postgres') {
|
|
147
|
+
return `
|
|
148
|
+
const failedResult = await ${dbVar}.query('UPDATE users SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = $1 WHERE id = $2 RETURNING failed_login_attempts', [new Date().toISOString(), user.id]);
|
|
149
|
+
const failedAttempts = failedResult.rows[0] ? failedResult.rows[0].failed_login_attempts : 1;
|
|
150
|
+
const remaining = MAX_FAILED_ATTEMPTS - failedAttempts;
|
|
151
|
+
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
|
|
152
|
+
const lockedUntil = new Date();
|
|
153
|
+
lockedUntil.setMinutes(lockedUntil.getMinutes() + LOCKOUT_DURATION_MINUTES);
|
|
154
|
+
await ${dbVar}.query('UPDATE users SET locked_until = $1 WHERE id = $2', [lockedUntil.toISOString(), user.id]);
|
|
155
|
+
return res.status(423).json({
|
|
156
|
+
error: 'Account locked',
|
|
157
|
+
message: \`Account locked due to \${MAX_FAILED_ATTEMPTS} failed attempts. Try again in \${LOCKOUT_DURATION_MINUTES} minutes.\`
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
`;
|
|
161
|
+
} else {
|
|
162
|
+
return `
|
|
163
|
+
${dbVar}.query('UPDATE users SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = ? WHERE id = ?', [new Date().toISOString(), user.id]);
|
|
164
|
+
const failedRow = ${dbVar}.query('SELECT failed_login_attempts FROM users WHERE id = ?', [user.id]);
|
|
165
|
+
const failedAttempts = failedRow.rows[0] ? failedRow.rows[0].failed_login_attempts : 1;
|
|
166
|
+
const remaining = MAX_FAILED_ATTEMPTS - failedAttempts;
|
|
167
|
+
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
|
|
168
|
+
const lockedUntil = new Date();
|
|
169
|
+
lockedUntil.setMinutes(lockedUntil.getMinutes() + LOCKOUT_DURATION_MINUTES);
|
|
170
|
+
${dbVar}.query('UPDATE users SET locked_until = ? WHERE id = ?', [lockedUntil.toISOString(), user.id]);
|
|
171
|
+
return res.status(423).json({
|
|
172
|
+
error: 'Account locked',
|
|
173
|
+
message: \`Account locked due to \${MAX_FAILED_ATTEMPTS} failed attempts. Try again in \${LOCKOUT_DURATION_MINUTES} minutes.\`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function generateResetBlock(dbVar, paramStyle) {
|
|
181
|
+
const param = paramStyle === 'postgres' ? '$1' : '?';
|
|
182
|
+
return `
|
|
183
|
+
await ${dbVar}.query('UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_failed_login = NULL WHERE id = ${param}', [user.id]);
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function generateMigrationSql(dbType) {
|
|
188
|
+
if (dbType === 'postgres') {
|
|
189
|
+
return `-- SecureAuth: Add account lockout columns
|
|
190
|
+
ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER DEFAULT 0;
|
|
191
|
+
ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TEXT;
|
|
192
|
+
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_failed_login TEXT;
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
return `-- SecureAuth: Add account lockout columns
|
|
196
|
+
ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0;
|
|
197
|
+
ALTER TABLE users ADD COLUMN locked_until TEXT;
|
|
198
|
+
ALTER TABLE users ADD COLUMN last_failed_login TEXT;
|
|
199
|
+
`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function addEnvVars(content) {
|
|
203
|
+
const requireRegex = /(?:const|let|var)\s+\w+\s*=\s*require\([^)]+\)/g;
|
|
204
|
+
let lastMatch = null;
|
|
205
|
+
let m;
|
|
206
|
+
while ((m = requireRegex.exec(content)) !== null) {
|
|
207
|
+
lastMatch = m;
|
|
208
|
+
}
|
|
209
|
+
if (lastMatch) {
|
|
210
|
+
const insertPos = content.indexOf('\n', lastMatch.index);
|
|
211
|
+
const splitPos = insertPos !== -1 ? insertPos + 1 : content.length;
|
|
212
|
+
return {
|
|
213
|
+
content: content.slice(0, splitPos) + generateLockoutEnvBlock() + content.slice(splitPos),
|
|
214
|
+
modified: true
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { content: generateLockoutEnvBlock() + '\n' + content, modified: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function addHelper(content) {
|
|
221
|
+
const loginRouteRegex = /(?:router|app)\s*\.\s*(?:post|get)\s*\(\s*['"](?:\/api\/)?(?:login|signin)['"]/;
|
|
222
|
+
const loginMatch = content.match(loginRouteRegex);
|
|
223
|
+
if (!loginMatch) return { content, modified: false };
|
|
224
|
+
|
|
225
|
+
const lineStart = content.lastIndexOf('\n', loginMatch.index) + 1;
|
|
226
|
+
const helper = generateHelperBlock();
|
|
227
|
+
return {
|
|
228
|
+
content: content.slice(0, lineStart) + helper + '\n' + content.slice(lineStart),
|
|
229
|
+
modified: true
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function addLockoutCheck(content, dbVar) {
|
|
234
|
+
const compareRegex = /(?:const\s+)?is(?:Valid|PasswordValid)\s*=\s*await\s+bcrypt\.compare\(/;
|
|
235
|
+
const compareMatch = content.match(compareRegex);
|
|
236
|
+
if (!compareMatch) return { content, modified: false };
|
|
237
|
+
|
|
238
|
+
const lineStart = content.lastIndexOf('\n', compareMatch.index) + 1;
|
|
239
|
+
const lockoutCheck = generateLockoutCheckBlock();
|
|
240
|
+
return {
|
|
241
|
+
content: content.slice(0, lineStart) + lockoutCheck + content.slice(lineStart),
|
|
242
|
+
modified: true
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function addIncrementReset(content, dbVar, paramStyle) {
|
|
247
|
+
const invalidPwMatch = content.match(/if\s*\(\s*!is(?:Valid|PasswordValid)\s*\)/);
|
|
248
|
+
if (!invalidPwMatch) return { content, modified: false };
|
|
249
|
+
|
|
250
|
+
const blockStart = content.indexOf('{', invalidPwMatch.index);
|
|
251
|
+
if (blockStart === -1) return { content, modified: false };
|
|
252
|
+
|
|
253
|
+
const incrementBlock = generateIncrementBlock(dbVar, paramStyle);
|
|
254
|
+
|
|
255
|
+
const afterBrace = content.slice(blockStart + 1);
|
|
256
|
+
const firstReturn = afterBrace.search(/\breturn\s+res\.status\(401\)/);
|
|
257
|
+
if (firstReturn === -1) return { content, modified: false };
|
|
258
|
+
|
|
259
|
+
const insertPos = blockStart + 1 + firstReturn;
|
|
260
|
+
content = content.slice(0, insertPos) + incrementBlock + content.slice(insertPos);
|
|
261
|
+
|
|
262
|
+
const closingBraceIndex = findMatchingBrace(content, blockStart);
|
|
263
|
+
if (closingBraceIndex === -1) return { content, modified: false };
|
|
264
|
+
|
|
265
|
+
const afterInvalidBlock = content.slice(closingBraceIndex + 1);
|
|
266
|
+
const successPwMatch = afterInvalidBlock.match(/if\s*\(!is(?:Valid|PasswordValid)\)/);
|
|
267
|
+
if (successPwMatch) {
|
|
268
|
+
const secondBlockStart = afterInvalidBlock.indexOf('{', successPwMatch.index);
|
|
269
|
+
if (secondBlockStart !== -1) {
|
|
270
|
+
const secondClosing = findMatchingBrace(afterInvalidBlock, secondBlockStart);
|
|
271
|
+
if (secondClosing !== -1) {
|
|
272
|
+
const afterSecond = afterInvalidBlock.slice(secondClosing + 1);
|
|
273
|
+
const resetBlock = generateResetBlock(dbVar, paramStyle);
|
|
274
|
+
const successMatch = afterSecond.match(/\breturn\s+res\.status\(2\d{2}\)/);
|
|
275
|
+
if (successMatch) {
|
|
276
|
+
const insertAfterSecond = closingBraceIndex + 1 + secondClosing + 1 + successMatch.index;
|
|
277
|
+
content = content.slice(0, insertAfterSecond) + resetBlock + content.slice(insertAfterSecond);
|
|
278
|
+
return { content, modified: true };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const afterInvalidBlockClean = content.slice(closingBraceIndex + 1);
|
|
285
|
+
const awaitCompareMatch = afterInvalidBlockClean.match(/await\s+bcrypt\.compare\(/);
|
|
286
|
+
if (awaitCompareMatch) {
|
|
287
|
+
return { content, modified: false };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const resetBlock = generateResetBlock(dbVar, paramStyle);
|
|
291
|
+
const successMatch = afterInvalidBlockClean.match(/\breturn\s+res\.status\(2\d{2}\)/);
|
|
292
|
+
if (successMatch) {
|
|
293
|
+
const insertPos3 = closingBraceIndex + 1 + successMatch.index;
|
|
294
|
+
content = content.slice(0, insertPos3) + resetBlock + content.slice(insertPos3);
|
|
295
|
+
return { content, modified: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { content, modified: false };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function addStatus423Return(content, dbVar, paramStyle) {
|
|
302
|
+
const lockedResponse = `\n return res.status(423).json({
|
|
303
|
+
error: 'Account locked',
|
|
304
|
+
message: \`Account locked due to \${MAX_FAILED_ATTEMPTS} failed attempts. Try again in \${LOCKOUT_DURATION_MINUTES} minutes.\`
|
|
305
|
+
});`;
|
|
306
|
+
const ifBlockRegex = /if\s*\(\s*failedAttempts\s*>=\s*MAX_FAILED_ATTEMPTS\s*\)\s*\{/;
|
|
307
|
+
const ifMatch = content.match(ifBlockRegex);
|
|
308
|
+
if (!ifMatch) return { content, modified: false };
|
|
309
|
+
|
|
310
|
+
const blockStart = content.indexOf('{', ifMatch.index);
|
|
311
|
+
const beforeLock = content.slice(blockStart + 1).match(/\bawait\s+dbVar\.query\(/);
|
|
312
|
+
if (beforeLock) {
|
|
313
|
+
const insertAfterLock = blockStart + 1 + beforeLock.index + beforeLock[0].length;
|
|
314
|
+
const semicolon = content.indexOf(';', insertAfterLock);
|
|
315
|
+
if (semicolon !== -1) {
|
|
316
|
+
content = content.slice(0, semicolon + 1) + lockedResponse + content.slice(semicolon + 1);
|
|
317
|
+
return { content, modified: true };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { content, modified: false };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function findMatchingBrace(str, openPos) {
|
|
324
|
+
if (str[openPos] !== '{') return -1;
|
|
325
|
+
let depth = 0;
|
|
326
|
+
for (let i = openPos; i < str.length; i++) {
|
|
327
|
+
if (str[i] === '{') depth++;
|
|
328
|
+
else if (str[i] === '}') {
|
|
329
|
+
depth--;
|
|
330
|
+
if (depth === 0) return i;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return -1;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function writeMigration(targetDir, dbType) {
|
|
337
|
+
const migrationsDir = path.join(targetDir, 'migrations');
|
|
338
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
339
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const timestamp = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);
|
|
343
|
+
const fileName = `${timestamp}_add_account_lockout.sql`;
|
|
344
|
+
const filePath = path.join(migrationsDir, fileName);
|
|
345
|
+
|
|
346
|
+
if (fs.existsSync(filePath)) {
|
|
347
|
+
logger.warn(`Migration ${fileName} already exists — skipping`);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const sql = generateMigrationSql(dbType);
|
|
352
|
+
fs.writeFileSync(filePath, sql, 'utf-8');
|
|
353
|
+
logger.success(`Created migration: migrations/${fileName}`);
|
|
354
|
+
return filePath;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function patchExisting(targetDir, answers) {
|
|
358
|
+
const hasLockout = answers.features && answers.features.includes('accountLockout');
|
|
359
|
+
if (!hasLockout) {
|
|
360
|
+
logger.info('Account lockout not selected — skipping patcher');
|
|
361
|
+
return { patched: false };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const authFile = findAuthRouteFile(targetDir);
|
|
365
|
+
if (!authFile) {
|
|
366
|
+
logger.warn('Could not find existing auth route file — lockout not patched');
|
|
367
|
+
return { patched: false };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
logger.info(`Found auth route: ${path.relative(targetDir, authFile)}`);
|
|
371
|
+
|
|
372
|
+
let content = fs.readFileSync(authFile, 'utf-8');
|
|
373
|
+
if (isAlreadyPatched(content)) {
|
|
374
|
+
logger.info('Auth route already has lockout protection');
|
|
375
|
+
const migrationPath = writeMigration(targetDir, detectDatabaseType(targetDir));
|
|
376
|
+
return { patched: true, alreadyPatched: true, authFile, migrationPath };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const dbType = detectDatabaseType(targetDir);
|
|
380
|
+
const paramStyle = detectParamStyle(content);
|
|
381
|
+
const dbVar = detectDbVariable(content);
|
|
382
|
+
|
|
383
|
+
logger.info(`Database type: ${dbType}, param style: ${paramStyle}, DB variable: \`${dbVar}\``);
|
|
384
|
+
|
|
385
|
+
let result = addEnvVars(content);
|
|
386
|
+
content = result.content;
|
|
387
|
+
|
|
388
|
+
result = addHelper(content);
|
|
389
|
+
content = result.content;
|
|
390
|
+
|
|
391
|
+
result = addLockoutCheck(content, dbVar);
|
|
392
|
+
content = result.content;
|
|
393
|
+
|
|
394
|
+
result = addIncrementReset(content, dbVar, paramStyle);
|
|
395
|
+
content = result.content;
|
|
396
|
+
|
|
397
|
+
fs.writeFileSync(authFile, content, 'utf-8');
|
|
398
|
+
logger.success(`Patched ${path.relative(targetDir, authFile)} with account lockout`);
|
|
399
|
+
|
|
400
|
+
const migrationPath = writeMigration(targetDir, dbType);
|
|
401
|
+
|
|
402
|
+
logger.info('Add these to your .env file for lockout configuration:');
|
|
403
|
+
logger.info(' MAX_FAILED_ATTEMPTS=5');
|
|
404
|
+
logger.info(' LOCKOUT_DURATION_MINUTES=30');
|
|
405
|
+
|
|
406
|
+
return { patched: true, authFile, migrationPath, dbType };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = { patchExisting, findEntryPoint, findAuthRouteFile, detectDatabaseType, detectDbVariable, detectParamStyle, isAlreadyPatched, writeMigration };
|
package/src/questions.js
CHANGED
|
@@ -98,6 +98,21 @@ async function askFeatures(defaults) {
|
|
|
98
98
|
return features;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
async function askInstallMode(defaults) {
|
|
102
|
+
if (defaults) return 'patch';
|
|
103
|
+
const { mode } = await prompt({
|
|
104
|
+
type: 'select',
|
|
105
|
+
name: 'mode',
|
|
106
|
+
message: 'How would you like to install SecureAuth?',
|
|
107
|
+
choices: [
|
|
108
|
+
{ name: 'patch', message: 'Patch existing login route with account lockout (Recommended)' },
|
|
109
|
+
{ name: 'alongside', message: 'Add alongside existing auth (mount at /api/secure-auth)' }
|
|
110
|
+
],
|
|
111
|
+
initial: 0
|
|
112
|
+
});
|
|
113
|
+
return mode;
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
async function askConfirm(defaults) {
|
|
102
117
|
if (defaults) return true;
|
|
103
118
|
const { confirmed } = await prompt({
|
|
@@ -117,5 +132,6 @@ module.exports = {
|
|
|
117
132
|
askPort,
|
|
118
133
|
askJwtSecret,
|
|
119
134
|
askFeatures,
|
|
135
|
+
askInstallMode,
|
|
120
136
|
askConfirm
|
|
121
137
|
};
|
package/src/scaffolder.js
CHANGED
|
@@ -117,9 +117,97 @@ function scaffold(targetDir, answers) {
|
|
|
117
117
|
fileCount++;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
postProcess(targetDir);
|
|
120
121
|
return fileCount;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
function postProcessExisting(targetDir) {
|
|
125
|
+
const serverFiles = ['server.js', 'app.js', 'index.js'];
|
|
126
|
+
let entryContent = null;
|
|
127
|
+
let entryPath = null;
|
|
128
|
+
|
|
129
|
+
for (const f of serverFiles) {
|
|
130
|
+
const p = path.join(targetDir, f);
|
|
131
|
+
if (fs.existsSync(p)) {
|
|
132
|
+
entryContent = fs.readFileSync(p, 'utf-8');
|
|
133
|
+
entryPath = p;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!entryContent) return;
|
|
138
|
+
|
|
139
|
+
const mods = [];
|
|
140
|
+
|
|
141
|
+
const dotenvRegex = /require\(['"]dotenv['"]\)\.config\(\);/;
|
|
142
|
+
if (!dotenvRegex.test(entryContent) && !entryContent.includes("require('dotenv')")) {
|
|
143
|
+
const firstRequireEnd = entryContent.indexOf(';') + 1;
|
|
144
|
+
if (firstRequireEnd > 0) {
|
|
145
|
+
mods.push({ pos: 0, end: firstRequireEnd, insert: `const path = require('path');\nrequire('dotenv').config({ path: path.join(__dirname, '.env') });\n` });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (mods.length > 0) {
|
|
150
|
+
for (const m of mods.reverse()) {
|
|
151
|
+
entryContent = entryContent.slice(0, m.end) + '\n' + m.insert + entryContent.slice(m.end);
|
|
152
|
+
}
|
|
153
|
+
fs.writeFileSync(entryPath, entryContent, 'utf-8');
|
|
154
|
+
logger.info(`Patched ${path.basename(entryPath)} with dotenv config`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function postProcess(targetDir) {
|
|
159
|
+
const files = {
|
|
160
|
+
'controllers/authController.js': [
|
|
161
|
+
{
|
|
162
|
+
search: /(const failedAttempts = await userModel\.incrementFailedAttempts\(user\.id\);)(?!\s+const remaining)/,
|
|
163
|
+
replace: '$1\n const remaining = MAX_FAILED_ATTEMPTS - failedAttempts;'
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
'server.js': [
|
|
167
|
+
{
|
|
168
|
+
search: /require\('dotenv'\)\.config\(\);/,
|
|
169
|
+
replace: "const path = require('path');\nrequire('dotenv').config({ path: path.join(__dirname, '.env') });"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
search: /initDatabase\(\)\s*\.then\(async\s*\(\)\s*=>\s*\{[\s\S]*?await seedAdminUser\(\);[\s\S]*?await seedDemoUser\(\);[\s\S]*?dbReady\s*=\s*true;[\s\S]*?console\.log\('Database initialized and seeded'\);\s*\}\)[\s\S]*?\.catch\(err\s*=>\s*\{[\s\S]*?console\.error\('Database initialization failed:',\s*err\.message\);\s*\}\);/,
|
|
173
|
+
replace: "try {\n initDatabase();\n seedAdminUser();\n seedDemoUser();\n dbReady = true;\n console.log('Database initialized and seeded');\n} catch (err) {\n console.error('Database initialization failed:', err.message);\n}"
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
'database/db.sqlite.js': [
|
|
177
|
+
{
|
|
178
|
+
search: /query:\s*\(text,\s*params\)\s*=>\s*\{[^}]*const stmt\s*=\s*db\.prepare\(text\);[^}]*const rows\s*=\s*params\s*\?\s*stmt\.all\(\.\.\.params\)\s*:\s*stmt\.all\(\);[^}]*return\s*\{\s*rows\s*\};[^}]*\}/,
|
|
179
|
+
replace: "query: (text, params) => {\n const stmt = db.prepare(text);\n const sql = text.trim().toUpperCase();\n if (sql.startsWith('SELECT')) {\n const rows = params ? stmt.all(...params) : stmt.all();\n return { rows };\n }\n const info = params ? stmt.run(...params) : stmt.run();\n return { rows: [], changes: info.changes };\n }"
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
'routes/authRoutes.js': [
|
|
183
|
+
{
|
|
184
|
+
search: /router\.post\('\/demo\/login', authController\.demoLogin\);\s*/,
|
|
185
|
+
replace: ''
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const [relative, rules] of Object.entries(files)) {
|
|
191
|
+
const filePath = path.join(targetDir, relative);
|
|
192
|
+
if (!fs.existsSync(filePath)) continue;
|
|
193
|
+
try {
|
|
194
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
195
|
+
const before = content;
|
|
196
|
+
for (const rule of rules) {
|
|
197
|
+
content = content.replace(rule.search, rule.replace);
|
|
198
|
+
}
|
|
199
|
+
if (content !== before) {
|
|
200
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
201
|
+
logger.info(`Patched ${relative}`);
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// skip unreadable files
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
postProcessExisting(targetDir);
|
|
209
|
+
}
|
|
210
|
+
|
|
123
211
|
function collectFiles(dir) {
|
|
124
212
|
const results = [];
|
|
125
213
|
for (const entry of fs.readdirSync(dir)) {
|