mbkauthe 5.0.2 → 5.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/README.md +9 -4
- package/docs/guides/configuration.md +1 -6
- package/docs/guides/database.md +1 -1
- package/docs/schema/db.sql +22 -1
- package/index.d.ts +19 -6
- package/lib/config/cookies.js +35 -10
- package/lib/config/index.js +162 -153
- package/lib/config/security.js +48 -8
- package/lib/db/AuthRepository.js +8 -8
- package/lib/main.js +2 -0
- package/lib/middleware/auth.js +36 -9
- package/lib/middleware/index.js +20 -20
- package/lib/routes/auth.js +21 -26
- package/lib/routes/misc.js +87 -24
- package/lib/routes/oauth.js +5 -5
- package/lib/utils/appAccess.js +8 -0
- package/lib/utils/dbQueryLogger.js +1 -1
- package/lib/utils/redirect.js +14 -0
- package/lib/utils/urlSafety.js +67 -0
- package/package.json +10 -7
- package/public/main.js +158 -99
- package/test.spec.js +73 -1
- package/views/head.handlebars +1 -0
- package/views/pages/accountSwitch.handlebars +11 -11
- package/views/pages/loginmbkauthe.handlebars +32 -24
- package/views/pages/test.handlebars +2 -3
- package/views/profilemenu.handlebars +1 -15
package/lib/config/index.js
CHANGED
|
@@ -5,140 +5,181 @@ import { createLogger } from "../utils/logger.js";
|
|
|
5
5
|
dotenv.config();
|
|
6
6
|
const logConfig = createLogger("config");
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const CONFIG_KEYS = [
|
|
9
|
+
"APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
|
|
10
|
+
"IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
|
|
11
|
+
"GITHUB_LOGIN_ENABLED", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_LOGIN_ENABLED", "GOOGLE_CLIENT_ID",
|
|
12
|
+
"GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
DEVICE_TRUST_DURATION_DAYS: 7,
|
|
17
|
+
IS_DEPLOYED: 'false',
|
|
18
|
+
MBKAUTH_TWO_FA_ENABLE: 'false',
|
|
19
|
+
COOKIE_EXPIRE_TIME: 2,
|
|
20
|
+
loginRedirectURL: '/dashboard',
|
|
21
|
+
GITHUB_LOGIN_ENABLED: 'false',
|
|
22
|
+
GOOGLE_LOGIN_ENABLED: 'false',
|
|
23
|
+
MAX_SESSIONS_PER_USER: 5
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const BOOLEAN_KEYS = ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'];
|
|
27
|
+
const STRING_KEYS = [
|
|
28
|
+
"APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "LOGIN_DB", "DOMAIN", "loginRedirectURL",
|
|
29
|
+
"GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
|
|
30
|
+
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"
|
|
31
|
+
];
|
|
32
|
+
const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED", "LOGIN_DB",
|
|
33
|
+
"MBKAUTH_TWO_FA_ENABLE", "DOMAIN"];
|
|
34
|
+
|
|
35
|
+
const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
36
|
+
const isBlank = (value) => value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
|
37
|
+
const hasValue = (value) => !isBlank(value);
|
|
38
|
+
|
|
39
|
+
function parseJsonEnv(name, { required = false } = {}) {
|
|
40
|
+
const raw = process.env[name];
|
|
41
|
+
if (isBlank(raw)) {
|
|
42
|
+
if (required) {
|
|
43
|
+
throw new Error(`[mbkauthe] Configuration Error:\n - process.env.${name} is not defined`);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
11
47
|
|
|
12
|
-
// Parse and validate mbkautheVar
|
|
13
|
-
let mbkautheVar;
|
|
14
48
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
throw new Error(
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (!isPlainObject(parsed)) {
|
|
51
|
+
throw new Error(`${name} must be a valid object`);
|
|
18
52
|
}
|
|
19
|
-
|
|
53
|
+
return parsed;
|
|
20
54
|
} catch (error) {
|
|
21
|
-
|
|
22
|
-
|
|
55
|
+
const message = error.message === `${name} must be a valid object`
|
|
56
|
+
? error.message
|
|
57
|
+
: `Invalid JSON in process.env.${name}`;
|
|
58
|
+
if (required) {
|
|
59
|
+
throw new Error(`[mbkauthe] Configuration Error:\n - ${message}`);
|
|
23
60
|
}
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
console.warn(`[mbkauthe] ${message}, ignoring it`);
|
|
62
|
+
return null;
|
|
26
63
|
}
|
|
64
|
+
}
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error(`[mbkauthe] Configuration Error:\n - ${errors.join('\n - ')}`);
|
|
31
|
-
}
|
|
66
|
+
function applySharedFallbacks(config, sharedConfig, usedFromShared) {
|
|
67
|
+
if (!sharedConfig) return;
|
|
32
68
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
mbkauthShared = JSON.parse(process.env.mbkauthShared);
|
|
38
|
-
if (mbkauthShared && typeof mbkauthShared !== 'object') {
|
|
39
|
-
console.warn(`[mbkauthe] mbkauthShared is not a valid object, ignoring it`);
|
|
40
|
-
mbkauthShared = null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
} catch (error) {
|
|
44
|
-
console.warn(`[mbkauthe] Invalid JSON in process.env.mbkauthShared, ignoring it`);
|
|
45
|
-
mbkauthShared = null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Merge fallback settings: for any key missing or empty in mbkautheVar, check mbkauthShared
|
|
49
|
-
const usedFromShared = [];
|
|
50
|
-
const usedDefaults = [];
|
|
51
|
-
const applyFallback = (source, sourceName) => {
|
|
52
|
-
if (!source) return;
|
|
53
|
-
Object.keys(source).forEach(key => {
|
|
54
|
-
const val = source[key];
|
|
55
|
-
if ((mbkautheVar[key] === undefined || (typeof mbkautheVar[key] === 'string' && mbkautheVar[key].trim() === '')) &&
|
|
56
|
-
val !== undefined && !(typeof val === 'string' && val.trim() === '')) {
|
|
57
|
-
mbkautheVar[key] = val;
|
|
58
|
-
if (sourceName === 'mbkauthShared') usedFromShared.push(key);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
applyFallback(mbkauthShared, 'mbkauthShared');
|
|
64
|
-
|
|
65
|
-
// Ensure specific keys are checked in mbkautheVar first, then mbkauthShared, then apply config defaults
|
|
66
|
-
const keysToCheck = [
|
|
67
|
-
"APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
|
|
68
|
-
"IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
|
|
69
|
-
"GITHUB_LOGIN_ENABLED", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_LOGIN_ENABLED", "GOOGLE_CLIENT_ID",
|
|
70
|
-
"GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
const defaults = {
|
|
74
|
-
DEVICE_TRUST_DURATION_DAYS: 7,
|
|
75
|
-
IS_DEPLOYED: 'false',
|
|
76
|
-
MBKAUTH_TWO_FA_ENABLE: 'false',
|
|
77
|
-
COOKIE_EXPIRE_TIME: 2,
|
|
78
|
-
loginRedirectURL: '/dashboard',
|
|
79
|
-
GITHUB_LOGIN_ENABLED: 'false',
|
|
80
|
-
GOOGLE_LOGIN_ENABLED: 'false',
|
|
81
|
-
MAX_SESSIONS_PER_USER: 5
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
keysToCheck.forEach(key => {
|
|
85
|
-
const current = mbkautheVar[key];
|
|
86
|
-
const isEmpty = current === undefined || (typeof current === 'string' && current.trim() === '');
|
|
87
|
-
if (isEmpty) {
|
|
88
|
-
if (mbkauthShared && mbkauthShared[key] !== undefined && !(typeof mbkauthShared[key] === 'string' && mbkauthShared[key].trim() === '')) {
|
|
89
|
-
mbkautheVar[key] = mbkauthShared[key];
|
|
90
|
-
if (!usedFromShared.includes(key)) usedFromShared.push(key);
|
|
91
|
-
} else if (defaults[key] !== undefined) {
|
|
92
|
-
mbkautheVar[key] = defaults[key];
|
|
93
|
-
usedDefaults.push(key);
|
|
94
|
-
}
|
|
69
|
+
Object.entries(sharedConfig).forEach(([key, value]) => {
|
|
70
|
+
if (isBlank(config[key]) && hasValue(value)) {
|
|
71
|
+
config[key] = value;
|
|
72
|
+
usedFromShared.add(key);
|
|
95
73
|
}
|
|
96
74
|
});
|
|
75
|
+
}
|
|
97
76
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
} else if (typeof val === 'string') {
|
|
104
|
-
const norm = val.trim().toLowerCase();
|
|
105
|
-
// Accept 'f' as shorthand for false but normalize it to 'false'
|
|
106
|
-
mbkautheVar[k] = (norm === 'f') ? 'false' : norm;
|
|
77
|
+
function applyDefaults(config, usedDefaults) {
|
|
78
|
+
CONFIG_KEYS.forEach((key) => {
|
|
79
|
+
if (isBlank(config[key]) && DEFAULT_CONFIG[key] !== undefined) {
|
|
80
|
+
config[key] = DEFAULT_CONFIG[key];
|
|
81
|
+
usedDefaults.add(key);
|
|
107
82
|
}
|
|
108
83
|
});
|
|
84
|
+
}
|
|
109
85
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
86
|
+
function normalizeBooleanFlag(config, key, errors) {
|
|
87
|
+
const value = config[key];
|
|
88
|
+
if (typeof value === 'boolean') {
|
|
89
|
+
config[key] = value ? 'true' : 'false';
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
115
92
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
93
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
94
|
+
if (normalized === 'f') {
|
|
95
|
+
config[key] = 'false';
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (normalized === 'true' || normalized === 'false') {
|
|
100
|
+
config[key] = normalized;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!isBlank(value)) {
|
|
105
|
+
errors.push(`mbkautheVar.${key} must be either 'true' or 'false' or 'f'`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizePositiveNumber(config, key, errors) {
|
|
110
|
+
const numericValue = Number(config[key]);
|
|
111
|
+
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
|
112
|
+
errors.push(`mbkautheVar.${key} must be a valid positive number`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
config[key] = numericValue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizePositiveInteger(config, key, errors) {
|
|
119
|
+
const numericValue = Number(config[key]);
|
|
120
|
+
if (!Number.isInteger(numericValue) || numericValue <= 0) {
|
|
121
|
+
errors.push(`mbkautheVar.${key} must be a valid positive integer`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
config[key] = numericValue;
|
|
125
|
+
}
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
127
|
+
function normalizeString(config, key) {
|
|
128
|
+
if (hasValue(config[key])) {
|
|
129
|
+
config[key] = String(config[key]).trim();
|
|
125
130
|
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeAndValidateConfig(config, errors) {
|
|
134
|
+
STRING_KEYS.forEach((key) => normalizeString(config, key));
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
errors.push("mbkautheVar.MBKAUTH_TWO_FA_ENABLE must be either 'true' or 'false' or 'f'");
|
|
136
|
+
if (hasValue(config.APP_NAME)) {
|
|
137
|
+
config.APP_NAME = config.APP_NAME.toLowerCase();
|
|
130
138
|
}
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
if (hasValue(config.DOMAIN)) {
|
|
141
|
+
const domain = config.DOMAIN.toLowerCase().replace(/^\.+/, '');
|
|
142
|
+
config.DOMAIN = domain;
|
|
143
|
+
if (domain.includes('://') || domain.includes('/') || domain.includes(':')) {
|
|
144
|
+
errors.push("mbkautheVar.DOMAIN must be a hostname only, without protocol, path, or port");
|
|
145
|
+
}
|
|
135
146
|
}
|
|
136
147
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
if (hasValue(config.loginRedirectURL)) {
|
|
149
|
+
const redirectUrl = String(config.loginRedirectURL).trim();
|
|
150
|
+
config.loginRedirectURL = redirectUrl;
|
|
151
|
+
if (!redirectUrl.startsWith('/') || redirectUrl.startsWith('//')) {
|
|
152
|
+
errors.push("mbkautheVar.loginRedirectURL must be a relative path starting with '/'");
|
|
153
|
+
}
|
|
140
154
|
}
|
|
141
155
|
|
|
156
|
+
BOOLEAN_KEYS.forEach((key) => normalizeBooleanFlag(config, key, errors));
|
|
157
|
+
normalizePositiveNumber(config, "COOKIE_EXPIRE_TIME", errors);
|
|
158
|
+
normalizePositiveNumber(config, "DEVICE_TRUST_DURATION_DAYS", errors);
|
|
159
|
+
normalizePositiveInteger(config, "MAX_SESSIONS_PER_USER", errors);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Comprehensive validation function
|
|
163
|
+
function validateConfiguration() {
|
|
164
|
+
const errors = [];
|
|
165
|
+
const usedFromShared = new Set();
|
|
166
|
+
const usedDefaults = new Set();
|
|
167
|
+
const mbkautheVar = parseJsonEnv("mbkautheVar", { required: true });
|
|
168
|
+
const mbkauthShared = parseJsonEnv("mbkauthShared");
|
|
169
|
+
|
|
170
|
+
applySharedFallbacks(mbkautheVar, mbkauthShared, usedFromShared);
|
|
171
|
+
applyDefaults(mbkautheVar, usedDefaults);
|
|
172
|
+
normalizeAndValidateConfig(mbkautheVar, errors);
|
|
173
|
+
|
|
174
|
+
// Validate required keys
|
|
175
|
+
// COOKIE_EXPIRE_TIME is not required but if provided must be valid, COOKIE_EXPIRE_TIME by default is 2 days
|
|
176
|
+
// loginRedirectURL is not required but if provided must be valid, loginRedirectURL by default is /dashboard
|
|
177
|
+
REQUIRED_KEYS.forEach(key => {
|
|
178
|
+
if (isBlank(mbkautheVar[key])) {
|
|
179
|
+
errors.push(`mbkautheVar.${key} is required and cannot be empty`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
142
183
|
// Validate GitHub login configuration
|
|
143
184
|
if (mbkautheVar.GITHUB_LOGIN_ENABLED === "true") {
|
|
144
185
|
const hasGithubClientId = !!(mbkautheVar.GITHUB_APP_CLIENT_ID || mbkautheVar.GITHUB_CLIENT_ID);
|
|
@@ -154,50 +195,14 @@ function validateConfiguration() {
|
|
|
154
195
|
|
|
155
196
|
// Validate Google login configuration
|
|
156
197
|
if (mbkautheVar.GOOGLE_LOGIN_ENABLED === "true") {
|
|
157
|
-
if (
|
|
198
|
+
if (isBlank(mbkautheVar.GOOGLE_CLIENT_ID)) {
|
|
158
199
|
errors.push("mbkautheVar.GOOGLE_CLIENT_ID is required when GOOGLE_LOGIN_ENABLED is 'true'");
|
|
159
200
|
}
|
|
160
|
-
if (
|
|
201
|
+
if (isBlank(mbkautheVar.GOOGLE_CLIENT_SECRET)) {
|
|
161
202
|
errors.push("mbkautheVar.GOOGLE_CLIENT_SECRET is required when GOOGLE_LOGIN_ENABLED is 'true'");
|
|
162
203
|
}
|
|
163
204
|
}
|
|
164
205
|
|
|
165
|
-
// Validate COOKIE_EXPIRE_TIME if provided
|
|
166
|
-
if (mbkautheVar.COOKIE_EXPIRE_TIME !== undefined) {
|
|
167
|
-
const expireTime = parseFloat(mbkautheVar.COOKIE_EXPIRE_TIME);
|
|
168
|
-
if (isNaN(expireTime) || expireTime <= 0) {
|
|
169
|
-
errors.push("mbkautheVar.COOKIE_EXPIRE_TIME must be a valid positive number");
|
|
170
|
-
}
|
|
171
|
-
} else {
|
|
172
|
-
// Set default value
|
|
173
|
-
mbkautheVar.COOKIE_EXPIRE_TIME = 2;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Validate DEVICE_TRUST_DURATION_DAYS if provided
|
|
177
|
-
if (mbkautheVar.DEVICE_TRUST_DURATION_DAYS !== undefined) {
|
|
178
|
-
const trustDuration = parseFloat(mbkautheVar.DEVICE_TRUST_DURATION_DAYS);
|
|
179
|
-
if (isNaN(trustDuration) || trustDuration <= 0) {
|
|
180
|
-
errors.push("mbkautheVar.DEVICE_TRUST_DURATION_DAYS must be a valid positive number");
|
|
181
|
-
}
|
|
182
|
-
} else {
|
|
183
|
-
// Set default value
|
|
184
|
-
mbkautheVar.DEVICE_TRUST_DURATION_DAYS = 7;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Validate MAX_SESSIONS_PER_USER if provided (must be positive integer)
|
|
188
|
-
if (mbkautheVar.MAX_SESSIONS_PER_USER !== undefined) {
|
|
189
|
-
const maxSessions = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
|
|
190
|
-
if (isNaN(maxSessions) || maxSessions <= 0) {
|
|
191
|
-
errors.push("mbkautheVar.MAX_SESSIONS_PER_USER must be a valid positive integer");
|
|
192
|
-
} else {
|
|
193
|
-
// Normalize to integer
|
|
194
|
-
mbkautheVar.MAX_SESSIONS_PER_USER = maxSessions;
|
|
195
|
-
}
|
|
196
|
-
} else {
|
|
197
|
-
// Ensure default value is set
|
|
198
|
-
mbkautheVar.MAX_SESSIONS_PER_USER = 5;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
206
|
// Validate LOGIN_DB connection string format
|
|
202
207
|
if (mbkautheVar.LOGIN_DB && !mbkautheVar.LOGIN_DB.startsWith('postgresql://') && !mbkautheVar.LOGIN_DB.startsWith('postgres://')) {
|
|
203
208
|
errors.push("mbkautheVar.LOGIN_DB must be a valid PostgreSQL connection string");
|
|
@@ -211,14 +216,14 @@ function validateConfiguration() {
|
|
|
211
216
|
// Print consolidated configuration summary
|
|
212
217
|
const configParts = [];
|
|
213
218
|
if (mbkauthShared) {
|
|
214
|
-
configParts.push(`mbkauthShared: ${usedFromShared.
|
|
219
|
+
configParts.push(`mbkauthShared: ${usedFromShared.size} keys`);
|
|
215
220
|
}
|
|
216
|
-
if (usedDefaults.
|
|
217
|
-
configParts.push(`defaults: ${usedDefaults.
|
|
221
|
+
if (usedDefaults.size > 0) {
|
|
222
|
+
configParts.push(`defaults: ${usedDefaults.size} keys`);
|
|
218
223
|
}
|
|
219
224
|
const configSummary = configParts.length > 0 ? ` (${configParts.join(', ')})` : '';
|
|
220
225
|
logConfig(`Configuration loaded${configSummary}`);
|
|
221
|
-
return mbkautheVar;
|
|
226
|
+
return Object.freeze(mbkautheVar);
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
// Parse and validate mbkautheVar once
|
|
@@ -248,6 +253,10 @@ try {
|
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
import { setPasswordPepper } from "./security.js";
|
|
257
|
+
|
|
258
|
+
setPasswordPepper(mbkautheVar.SESSION_SECRET_KEY);
|
|
259
|
+
|
|
251
260
|
export { packageJson, appVersion, mbkautheVar };
|
|
252
|
-
export { hashPassword, hashApiToken } from "./security.js";
|
|
261
|
+
export { hashPassword, hashApiToken, verifyPassword } from "./security.js";
|
|
253
262
|
export { TOKEN_SCOPES, DEFAULT_SCOPE, canAccessMethod, isValidScope, getAvailableScopes } from "./tokenScopes.js";
|
package/lib/config/security.js
CHANGED
|
@@ -1,15 +1,55 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
|
+
import { promisify } from "util";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const pbkdf2Async = promisify(crypto.pbkdf2);
|
|
5
|
+
const PBKDF2_ITERATIONS = 100000;
|
|
6
|
+
const PBKDF2_KEYLEN = 64;
|
|
7
|
+
const PBKDF2_DIGEST = "sha512";
|
|
8
|
+
|
|
9
|
+
let passwordPepper = null;
|
|
10
|
+
|
|
11
|
+
export function setPasswordPepper(pepper) {
|
|
12
|
+
passwordPepper = pepper;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const getPasswordPepper = () => {
|
|
16
|
+
if (!passwordPepper) {
|
|
17
|
+
throw new Error("[mbkauthe] Password pepper not initialized");
|
|
18
|
+
}
|
|
19
|
+
return passwordPepper;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getPasswordSalt = (username) => `${username}:${getPasswordPepper()}`;
|
|
23
|
+
|
|
24
|
+
async function derivePasswordHash(password, username) {
|
|
25
|
+
const derived = await pbkdf2Async(password, getPasswordSalt(username), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
26
|
+
return derived.toString("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function derivePasswordHashSync(password, username) {
|
|
30
|
+
return crypto.pbkdf2Sync(password, getPasswordSalt(username), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST).toString("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function timingSafeHashEqual(stored, computed) {
|
|
34
|
+
const storedBuffer = Buffer.from(String(stored), "utf8");
|
|
35
|
+
const computedBuffer = Buffer.from(String(computed), "utf8");
|
|
36
|
+
return storedBuffer.length === computedBuffer.length && crypto.timingSafeEqual(storedBuffer, computedBuffer);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Password hashing using PBKDF2 with username + application pepper as salt
|
|
4
40
|
export const hashPassword = (password, username) => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
41
|
+
return derivePasswordHashSync(password, username);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const verifyPassword = async (password, username, storedHash) => {
|
|
45
|
+
if (!password || !username || !storedHash) return false;
|
|
46
|
+
|
|
47
|
+
const computed = await derivePasswordHash(password, username);
|
|
48
|
+
return timingSafeHashEqual(storedHash, computed);
|
|
8
49
|
};
|
|
9
50
|
|
|
10
51
|
// Hash an API token for storage
|
|
11
|
-
// Uses SHA-256 for fast, secure non-reversible hashing
|
|
12
52
|
export const hashApiToken = (token) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
};
|
|
53
|
+
if (!token) return null;
|
|
54
|
+
return crypto.createHash("sha256").update(token).digest("hex");
|
|
55
|
+
};
|
package/lib/db/AuthRepository.js
CHANGED
|
@@ -18,8 +18,8 @@ export class AuthRepository extends BaseRepository {
|
|
|
18
18
|
const fields = [
|
|
19
19
|
`s.id as sid`,
|
|
20
20
|
`s.expires_at`,
|
|
21
|
-
`u.id as uid`,
|
|
22
21
|
`u."UserName"`,
|
|
22
|
+
`u."UserId"`,
|
|
23
23
|
`u."Active"`,
|
|
24
24
|
`u."Role"`,
|
|
25
25
|
`u."AllowedApps"`
|
|
@@ -85,7 +85,7 @@ export class AuthRepository extends BaseRepository {
|
|
|
85
85
|
AND td."ExpiresAt" > NOW()
|
|
86
86
|
AND u."UserName" = td."UserName"
|
|
87
87
|
AND u."Active" = TRUE
|
|
88
|
-
RETURNING td."UserName", td."ExpiresAt", u."
|
|
88
|
+
RETURNING td."UserName", td."ExpiresAt", u."UserId", u."Active", u."Role", u."AllowedApps"
|
|
89
89
|
`;
|
|
90
90
|
|
|
91
91
|
const result = await this.executeRaw({
|
|
@@ -168,12 +168,12 @@ export class AuthRepository extends BaseRepository {
|
|
|
168
168
|
return result.rows?.[0] || null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
async updateLastLoginReturnProfile(
|
|
172
|
-
const query = `UPDATE "Users" SET "last_login" = NOW() WHERE "
|
|
171
|
+
async updateLastLoginReturnProfile(username) {
|
|
172
|
+
const query = `UPDATE "Users" SET "last_login" = NOW() WHERE "UserName" = $1 RETURNING "FullName", "Image"`;
|
|
173
173
|
const result = await this.executeRaw({
|
|
174
174
|
name: "login-update-last-login-return-profile",
|
|
175
175
|
text: query,
|
|
176
|
-
values: [
|
|
176
|
+
values: [username]
|
|
177
177
|
});
|
|
178
178
|
return result.rows?.[0] || null;
|
|
179
179
|
}
|
|
@@ -196,7 +196,7 @@ export class AuthRepository extends BaseRepository {
|
|
|
196
196
|
|
|
197
197
|
async getUserWithTwoFA(username, queryName = "login-get-user") {
|
|
198
198
|
const query = `
|
|
199
|
-
SELECT u.
|
|
199
|
+
SELECT u."UserName", u."UserId", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
|
|
200
200
|
tfa."TwoFAStatus", u."FullName", u."Image"
|
|
201
201
|
FROM "Users" u
|
|
202
202
|
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
@@ -222,7 +222,7 @@ export class AuthRepository extends BaseRepository {
|
|
|
222
222
|
|
|
223
223
|
async getOAuthUserByProviderId(provider, providerId) {
|
|
224
224
|
const { table, idColumn, queryName } = this.resolveOAuthProvider(provider);
|
|
225
|
-
const query = `SELECT ug.*, u."UserName", u."
|
|
225
|
+
const query = `SELECT ug.*, u."UserName", u."UserId", u."Role", u."Active", u."AllowedApps", tfa."TwoFAStatus"
|
|
226
226
|
FROM ${table} ug
|
|
227
227
|
JOIN "Users" u ON ug.user_name = u."UserName"
|
|
228
228
|
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
@@ -234,7 +234,7 @@ export class AuthRepository extends BaseRepository {
|
|
|
234
234
|
async getApiTokenByHash(tokenHash, queryName = "validate-api-token") {
|
|
235
235
|
const query = `
|
|
236
236
|
SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
|
|
237
|
-
u.
|
|
237
|
+
u."UserId", u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
|
|
238
238
|
FROM "ApiTokens" t
|
|
239
239
|
JOIN "Users" u ON t."UserName" = u."UserName"
|
|
240
240
|
WHERE t."TokenHash" = $1 LIMIT 1
|
package/lib/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import passport from 'passport';
|
|
|
5
5
|
import {
|
|
6
6
|
sessionConfig,
|
|
7
7
|
corsMiddleware,
|
|
8
|
+
securityHeadersMiddleware,
|
|
8
9
|
sessionRestorationMiddleware,
|
|
9
10
|
sessionCookieSyncMiddleware,
|
|
10
11
|
requestContextMiddleware
|
|
@@ -39,6 +40,7 @@ router.use(express.urlencoded({ extended: true }));
|
|
|
39
40
|
router.use(cookieParser());
|
|
40
41
|
|
|
41
42
|
// CORS and security headers
|
|
43
|
+
router.use(securityHeadersMiddleware);
|
|
42
44
|
router.use(corsMiddleware);
|
|
43
45
|
|
|
44
46
|
// Attach request context as early as possible so session-store queries are tied to the request.
|
package/lib/middleware/auth.js
CHANGED
|
@@ -13,10 +13,37 @@ const IS_DEV = process.env.env === 'dev' || process.env.test === 'dev' || proces
|
|
|
13
13
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
14
|
const isUuid = (val) => typeof val === 'string' && UUID_RE.test(val);
|
|
15
15
|
const MAX_API_TOKEN_LENGTH = 4096;
|
|
16
|
+
const API_TOKEN_LAST_USED_INTERVAL_MS = 15 * 60 * 1000;
|
|
16
17
|
const API_TOKEN_SESSION_RESTORE = Symbol('mbkauthe.apiTokenSessionRestore');
|
|
18
|
+
const apiTokenLastUsedCache = new Map();
|
|
17
19
|
const authRepo = new AuthRepository({ db: dblogin });
|
|
18
20
|
const logAuth = createLogger("auth");
|
|
19
21
|
|
|
22
|
+
function pruneApiTokenLastUsedCache(now) {
|
|
23
|
+
if (apiTokenLastUsedCache.size < 10000) return;
|
|
24
|
+
const staleBefore = now - (API_TOKEN_LAST_USED_INTERVAL_MS * 2);
|
|
25
|
+
for (const [tokenId, lastTouchedAt] of apiTokenLastUsedCache) {
|
|
26
|
+
if (lastTouchedAt < staleBefore) {
|
|
27
|
+
apiTokenLastUsedCache.delete(tokenId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function updateApiTokenLastUsedThrottled(tokenId) {
|
|
33
|
+
if (!tokenId) return;
|
|
34
|
+
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const lastTouchedAt = apiTokenLastUsedCache.get(tokenId) || 0;
|
|
37
|
+
if (now - lastTouchedAt < API_TOKEN_LAST_USED_INTERVAL_MS) return;
|
|
38
|
+
|
|
39
|
+
pruneApiTokenLastUsedCache(now);
|
|
40
|
+
apiTokenLastUsedCache.set(tokenId, now);
|
|
41
|
+
authRepo.updateApiTokenLastUsed(tokenId).catch(e => {
|
|
42
|
+
apiTokenLastUsedCache.delete(tokenId);
|
|
43
|
+
console.error(`[mbkauthe] Failed to update token usage:`, e);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
/**
|
|
21
48
|
* Decide if the incoming request should return JSON errors instead of HTML.
|
|
22
49
|
* Non-browser clients (API calls / AJAX) should get JSON.
|
|
@@ -80,11 +107,10 @@ async function validateTokenAuthentication(req) {
|
|
|
80
107
|
allowedApps = tokenAllowedApps;
|
|
81
108
|
}
|
|
82
109
|
|
|
83
|
-
|
|
84
|
-
authRepo.updateApiTokenLastUsed(row.id).catch(e => console.error(`[mbkauthe] Failed to update token usage:`, e));
|
|
110
|
+
updateApiTokenLastUsedThrottled(row.id);
|
|
85
111
|
|
|
86
112
|
return {
|
|
87
|
-
|
|
113
|
+
userId: row.UserId || undefined,
|
|
88
114
|
username: row.UserName,
|
|
89
115
|
fullname: row.FullName,
|
|
90
116
|
role: row.Role,
|
|
@@ -101,7 +127,7 @@ async function validateTokenAuthentication(req) {
|
|
|
101
127
|
|
|
102
128
|
function attachApiTokenUser(req, res, tokenUser) {
|
|
103
129
|
const user = {
|
|
104
|
-
|
|
130
|
+
userId: tokenUser.userId,
|
|
105
131
|
username: tokenUser.username,
|
|
106
132
|
fullname: tokenUser.fullname,
|
|
107
133
|
role: tokenUser.role,
|
|
@@ -159,7 +185,7 @@ function hasAppAccess(role, allowedApps) {
|
|
|
159
185
|
if (role === "SuperAdmin") return true;
|
|
160
186
|
return Array.isArray(allowedApps)
|
|
161
187
|
&& allowedApps.length > 0
|
|
162
|
-
&& allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
188
|
+
&& allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME);
|
|
163
189
|
}
|
|
164
190
|
|
|
165
191
|
function destroySessionCookies(req, res) {
|
|
@@ -308,11 +334,11 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
308
334
|
}
|
|
309
335
|
|
|
310
336
|
const hasWildcard = allowedApps.includes('*');
|
|
311
|
-
const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
337
|
+
const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME);
|
|
312
338
|
|
|
313
339
|
if (hasWildcard) {
|
|
314
340
|
const userHasApp = Array.isArray(userAllowedApps)
|
|
315
|
-
&& userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
341
|
+
&& userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME);
|
|
316
342
|
if (!userHasApp) {
|
|
317
343
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
|
|
318
344
|
}
|
|
@@ -372,7 +398,7 @@ async function validateApiSession(req, res, next) {
|
|
|
372
398
|
* Returns: true if session refreshed and valid, false if session invalidated
|
|
373
399
|
*/
|
|
374
400
|
async function reloadSessionUser(req, res) {
|
|
375
|
-
if (!req.session || !req.session.user || !req.session.user.
|
|
401
|
+
if (!req.session || !req.session.user || !req.session.user.username) return false;
|
|
376
402
|
try {
|
|
377
403
|
const { sessionId: currentSessionId } = req.session.user;
|
|
378
404
|
|
|
@@ -410,7 +436,7 @@ async function reloadSessionUser(req, res) {
|
|
|
410
436
|
if (row.Role !== 'SuperAdmin') {
|
|
411
437
|
const allowedApps = row.AllowedApps;
|
|
412
438
|
const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
|
|
413
|
-
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
439
|
+
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
414
440
|
req.session.destroy(() => { });
|
|
415
441
|
clearSessionCookies(res);
|
|
416
442
|
return false;
|
|
@@ -421,6 +447,7 @@ async function reloadSessionUser(req, res) {
|
|
|
421
447
|
req.session.user.username = row.UserName;
|
|
422
448
|
req.session.user.role = row.Role;
|
|
423
449
|
req.session.user.allowedApps = row.AllowedApps;
|
|
450
|
+
req.session.user.userId = row.UserId || undefined;
|
|
424
451
|
|
|
425
452
|
// Obtain fullname from client cookie cache when present else DB
|
|
426
453
|
if (typeof row.FullName === 'string' && row.FullName.trim() !== '') {
|