mbkauthe 5.0.1 → 5.0.4
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/LICENSE +161 -335
- package/README.md +9 -4
- package/docs/diagrams/auth-processes.mmd +37 -77
- package/docs/images/auth-processes.svg +1 -1
- package/lib/config/index.js +157 -152
- package/lib/middleware/auth.js +32 -6
- package/lib/middleware/index.js +1 -5
- package/lib/routes/auth.js +7 -7
- package/lib/routes/misc.js +55 -17
- package/lib/routes/oauth.js +1 -1
- package/package.json +10 -7
- package/public/main.js +146 -99
- package/views/head.handlebars +1 -0
- package/views/pages/test.handlebars +1 -2
- package/views/profilemenu.handlebars +1 -15
- package/docs/diagrams/c.md +0 -122
- package/docs/images/auth-process.svg +0 -102
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_SLUG", "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_SLUG", "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
|
+
}
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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;
|
|
125
123
|
}
|
|
124
|
+
config[key] = numericValue;
|
|
125
|
+
}
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
127
|
+
function normalizeString(config, key) {
|
|
128
|
+
if (hasValue(config[key])) {
|
|
129
|
+
config[key] = String(config[key]).trim();
|
|
130
130
|
}
|
|
131
|
+
}
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
function normalizeAndValidateConfig(config, errors) {
|
|
134
|
+
STRING_KEYS.forEach((key) => normalizeString(config, key));
|
|
135
|
+
|
|
136
|
+
if (hasValue(config.APP_NAME)) {
|
|
137
|
+
config.APP_NAME = config.APP_NAME.toLowerCase();
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
|
140
146
|
}
|
|
141
147
|
|
|
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
|
+
}
|
|
154
|
+
}
|
|
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
|
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,8 +107,7 @@ 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
|
id: row.uid,
|
|
@@ -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
|
}
|
|
@@ -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;
|
package/lib/middleware/index.js
CHANGED
|
@@ -122,11 +122,7 @@ export function sessionCookieSyncMiddleware(req, res, next) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
if (req.session && req.session.user) {
|
|
125
|
-
|
|
126
|
-
const currentDecryptedId = decryptSessionId(req.cookies.sessionId);
|
|
127
|
-
|
|
128
|
-
// Only set cookies if they're missing or different
|
|
129
|
-
if (currentDecryptedId !== req.session.user.sessionId) {
|
|
125
|
+
if (!req.cookies.sessionId) {
|
|
130
126
|
res.cookie("fullName", req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
131
127
|
const encryptedSessionId = encryptSessionId(req.session.user.sessionId);
|
|
132
128
|
if (encryptedSessionId) {
|
package/lib/routes/auth.js
CHANGED
|
@@ -78,7 +78,7 @@ async function fetchActiveSession(sessionId) {
|
|
|
78
78
|
if (row.Role !== 'SuperAdmin') {
|
|
79
79
|
const allowedApps = row.AllowedApps;
|
|
80
80
|
const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
|
|
81
|
-
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
81
|
+
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
@@ -121,7 +121,7 @@ export async function checkTrustedDevice(req, username) {
|
|
|
121
121
|
|
|
122
122
|
if (deviceUser.Role !== "SuperAdmin") {
|
|
123
123
|
const allowedApps = deviceUser.AllowedApps;
|
|
124
|
-
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
124
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
125
125
|
console.warn(`[mbkauthe] Trusted device check: User "${username}" is not authorized to use the application "${mbkautheVar.APP_NAME}"`);
|
|
126
126
|
return null;
|
|
127
127
|
}
|
|
@@ -398,7 +398,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
398
398
|
|
|
399
399
|
if (user.Role !== "SuperAdmin") {
|
|
400
400
|
const allowedApps = user.AllowedApps;
|
|
401
|
-
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
401
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
402
402
|
logError('Login attempt', ErrorCodes.APP_NOT_AUTHORIZED, {
|
|
403
403
|
username: user.UserName,
|
|
404
404
|
app: mbkautheVar.APP_NAME
|
|
@@ -476,7 +476,7 @@ router.get("/2fa", csrfProtection, (req, res) => {
|
|
|
476
476
|
layout: false,
|
|
477
477
|
customURL: redirectToUse,
|
|
478
478
|
csrfToken: req.csrfToken(),
|
|
479
|
-
appName: mbkautheVar.APP_NAME
|
|
479
|
+
appName: mbkautheVar.APP_NAME,
|
|
480
480
|
version: packageJson.version,
|
|
481
481
|
DEVICE_TRUST_DURATION_DAYS: mbkautheVar.DEVICE_TRUST_DURATION_DAYS
|
|
482
482
|
});
|
|
@@ -636,7 +636,7 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
|
|
|
636
636
|
const expired = row?.expires_at && new Date(row.expires_at) <= new Date();
|
|
637
637
|
const authorized = row && row.Active && (
|
|
638
638
|
row.Role === "SuperAdmin" ||
|
|
639
|
-
(Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
639
|
+
(Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME))
|
|
640
640
|
);
|
|
641
641
|
|
|
642
642
|
if (!row || expired || !authorized) {
|
|
@@ -774,7 +774,7 @@ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
|
|
|
774
774
|
userLoggedIn: !!req.session?.user,
|
|
775
775
|
username: req.session?.user?.username || '',
|
|
776
776
|
version: packageJson.version,
|
|
777
|
-
appName: mbkautheVar.APP_NAME
|
|
777
|
+
appName: mbkautheVar.APP_NAME,
|
|
778
778
|
csrfToken: req.csrfToken(),
|
|
779
779
|
// Last-login method flags for immediate server-side badge rendering
|
|
780
780
|
lastLoginMethod: lastLogin,
|
|
@@ -797,7 +797,7 @@ router.get("/accounts", LoginLimit, csrfProtection, (req, res) => {
|
|
|
797
797
|
layout: false,
|
|
798
798
|
customURL: safeRedirect,
|
|
799
799
|
version: packageJson.version,
|
|
800
|
-
appName: mbkautheVar.APP_NAME
|
|
800
|
+
appName: mbkautheVar.APP_NAME,
|
|
801
801
|
csrfToken: req.csrfToken(),
|
|
802
802
|
userLoggedIn: !!req.session?.user,
|
|
803
803
|
username: req.session?.user?.username,
|
package/lib/routes/misc.js
CHANGED
|
@@ -22,6 +22,23 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
22
22
|
const router = express.Router();
|
|
23
23
|
const authRepo = new AuthRepository({ db: dblogin });
|
|
24
24
|
const logMisc = createLogger("misc");
|
|
25
|
+
const PROFILE_IMAGE_CACHE_SECONDS = 300;
|
|
26
|
+
const PROFILE_IMAGE_CACHE_CONTROL = `private, max-age=${PROFILE_IMAGE_CACHE_SECONDS}, stale-while-revalidate=${PROFILE_IMAGE_CACHE_SECONDS}`;
|
|
27
|
+
const LATEST_VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
28
|
+
const LATEST_VERSION_FAILURE_CACHE_TTL_MS = 60 * 1000;
|
|
29
|
+
const latestVersionCache = {
|
|
30
|
+
value: null,
|
|
31
|
+
expiresAt: 0,
|
|
32
|
+
pending: null
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function setProfileImageCacheHeaders(res, eTag = null) {
|
|
36
|
+
res.setHeader('Cache-Control', PROFILE_IMAGE_CACHE_CONTROL);
|
|
37
|
+
if (eTag) {
|
|
38
|
+
res.setHeader('ETag', eTag);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
// Rate limiter for info/test routes
|
|
26
43
|
const LoginLimit = rateLimit({
|
|
27
44
|
windowMs: 1 * 60 * 1000,
|
|
@@ -76,9 +93,8 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
76
93
|
const serveDefaultIcon = () => {
|
|
77
94
|
const iconPath = path.join(__dirname, "..", "..", "public", "M.png");
|
|
78
95
|
res.setHeader('Content-Type', 'image/png');
|
|
79
|
-
// Ensure we don't override the Cache-Control we set earlier, or set a default if not set
|
|
80
96
|
if (!res.getHeader('Cache-Control')) {
|
|
81
|
-
res
|
|
97
|
+
setProfileImageCacheHeaders(res);
|
|
82
98
|
}
|
|
83
99
|
const stream = fs.createReadStream(iconPath);
|
|
84
100
|
stream.on('error', (err) => {
|
|
@@ -118,9 +134,7 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
118
134
|
// Generate ETag based on username and image URL
|
|
119
135
|
const eTag = `"${Buffer.from(username + ':' + imageUrl).toString('base64')}"`;
|
|
120
136
|
|
|
121
|
-
|
|
122
|
-
res.setHeader('Cache-Control', 'private, no-cache');
|
|
123
|
-
res.setHeader('ETag', eTag);
|
|
137
|
+
setProfileImageCacheHeaders(res, eTag);
|
|
124
138
|
|
|
125
139
|
// Check for conditional request
|
|
126
140
|
if (req.headers['if-none-match'] === eTag) {
|
|
@@ -450,20 +464,44 @@ router.get("/ErrorCode", (req, res) => {
|
|
|
450
464
|
}
|
|
451
465
|
});
|
|
452
466
|
|
|
453
|
-
// Fetch latest version from GitHub
|
|
454
|
-
export async function getLatestVersion() {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
467
|
+
// Fetch latest version from GitHub with a short in-memory cache.
|
|
468
|
+
export async function getLatestVersion({ forceRefresh = false } = {}) {
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
|
|
471
|
+
if (!forceRefresh && latestVersionCache.expiresAt > now) {
|
|
472
|
+
return latestVersionCache.value;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!forceRefresh && latestVersionCache.pending) {
|
|
476
|
+
return latestVersionCache.pending;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
latestVersionCache.pending = (async () => {
|
|
480
|
+
try {
|
|
481
|
+
const response = await fetch('https://raw.githubusercontent.com/MIbnEKhalid/mbkauthe/main/package.json');
|
|
482
|
+
if (!response.ok) {
|
|
483
|
+
console.error(`[mbkauthe] GitHub API responded with status ${response.status}`);
|
|
484
|
+
latestVersionCache.value = null;
|
|
485
|
+
latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_FAILURE_CACHE_TTL_MS;
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const latestPackageJson = await response.json();
|
|
490
|
+
const latestVersion = typeof latestPackageJson.version === 'string' ? latestPackageJson.version : null;
|
|
491
|
+
latestVersionCache.value = latestVersion;
|
|
492
|
+
latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_CACHE_TTL_MS;
|
|
493
|
+
return latestVersion;
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.error(`[mbkauthe] Error fetching latest version from GitHub`, error);
|
|
496
|
+
latestVersionCache.value = null;
|
|
497
|
+
latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_FAILURE_CACHE_TTL_MS;
|
|
459
498
|
return null;
|
|
499
|
+
} finally {
|
|
500
|
+
latestVersionCache.pending = null;
|
|
460
501
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
console.error(`[mbkauthe] Error fetching latest version from GitHub`, error);
|
|
465
|
-
return null;
|
|
466
|
-
}
|
|
502
|
+
})();
|
|
503
|
+
|
|
504
|
+
return latestVersionCache.pending;
|
|
467
505
|
}
|
|
468
506
|
|
|
469
507
|
// Version check with error handling
|
package/lib/routes/oauth.js
CHANGED
|
@@ -66,7 +66,7 @@ const createOAuthStrategy = async (provider, profile, done) => {
|
|
|
66
66
|
// Check if user is authorized for this app
|
|
67
67
|
if (user.Role !== "SuperAdmin") {
|
|
68
68
|
const allowedApps = user.AllowedApps;
|
|
69
|
-
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
69
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
70
70
|
const error = new Error(`Not authorized to use ${mbkautheVar.APP_NAME}`);
|
|
71
71
|
error.code = 'NOT_AUTHORIZED';
|
|
72
72
|
return done(error);
|