mbkauthe 2.5.0 → 3.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/LICENSE +339 -373
- package/README.md +142 -279
- package/docs/api.md +210 -82
- package/docs/db.md +1 -1
- package/docs/error-messages.md +557 -0
- package/index.d.ts +248 -0
- package/index.js +43 -32
- package/lib/config/cookies.js +52 -0
- package/lib/{config.js → config/index.js} +15 -95
- package/lib/config/security.js +8 -0
- package/lib/{pool.js → database/pool.js} +1 -1
- package/lib/main.js +38 -1038
- package/lib/{validateSessionAndRole.js → middleware/auth.js} +5 -3
- package/lib/middleware/index.js +106 -0
- package/lib/routes/auth.js +521 -0
- package/lib/routes/misc.js +263 -0
- package/lib/routes/oauth.js +325 -0
- package/lib/utils/errors.js +257 -0
- package/lib/utils/response.js +21 -0
- package/package.json +21 -11
- package/public/main.js +4 -4
- package/test.spec.js +178 -0
- package/views/Error/dError.handlebars +1 -1
- package/views/errorCodes.handlebars +341 -0
- package/views/showmessage.handlebars +7 -6
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { dblogin } from "
|
|
2
|
-
import { mbkautheVar
|
|
1
|
+
import { dblogin } from "../database/pool.js";
|
|
2
|
+
import { mbkautheVar } from "../config/index.js";
|
|
3
|
+
import { renderError } from "../utils/response.js";
|
|
4
|
+
import { clearSessionCookies } from "../config/cookies.js";
|
|
3
5
|
|
|
4
6
|
async function validateSession(req, res, next) {
|
|
5
7
|
if (!req.session.user) {
|
|
@@ -172,4 +174,4 @@ const authenticate = (authentication) => {
|
|
|
172
174
|
};
|
|
173
175
|
|
|
174
176
|
|
|
175
|
-
export { validateSession, checkRolePermission, validateSessionAndRole, authenticate };
|
|
177
|
+
export { validateSession, checkRolePermission, validateSessionAndRole, authenticate };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import session from "express-session";
|
|
2
|
+
import pgSession from "connect-pg-simple";
|
|
3
|
+
const PgSession = pgSession(session);
|
|
4
|
+
import { dblogin } from "../database/pool.js";
|
|
5
|
+
import { mbkautheVar } from "../config/index.js";
|
|
6
|
+
import { cachedCookieOptions } from "../config/cookies.js";
|
|
7
|
+
|
|
8
|
+
// Session configuration
|
|
9
|
+
export const sessionConfig = {
|
|
10
|
+
store: new PgSession({
|
|
11
|
+
pool: dblogin,
|
|
12
|
+
tableName: "session",
|
|
13
|
+
createTableIfMissing: true
|
|
14
|
+
}),
|
|
15
|
+
secret: mbkautheVar.SESSION_SECRET_KEY,
|
|
16
|
+
resave: false,
|
|
17
|
+
saveUninitialized: false,
|
|
18
|
+
proxy: true,
|
|
19
|
+
cookie: {
|
|
20
|
+
maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
|
|
21
|
+
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
|
|
22
|
+
httpOnly: true,
|
|
23
|
+
secure: mbkautheVar.IS_DEPLOYED === 'true',
|
|
24
|
+
sameSite: 'lax',
|
|
25
|
+
path: '/'
|
|
26
|
+
},
|
|
27
|
+
name: 'mbkauthe.sid'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// CORS middleware
|
|
31
|
+
export function corsMiddleware(req, res, next) {
|
|
32
|
+
const origin = req.headers.origin;
|
|
33
|
+
if (origin) {
|
|
34
|
+
try {
|
|
35
|
+
const originUrl = new URL(origin);
|
|
36
|
+
const allowedDomain = `.${mbkautheVar.DOMAIN}`;
|
|
37
|
+
// Exact match or subdomain match
|
|
38
|
+
if (originUrl.hostname === mbkautheVar.DOMAIN ||
|
|
39
|
+
(originUrl.hostname.endsWith(allowedDomain) && originUrl.hostname.charAt(originUrl.hostname.length - allowedDomain.length - 1) !== '.')) {
|
|
40
|
+
res.header('Access-Control-Allow-Origin', origin);
|
|
41
|
+
res.header('Access-Control-Allow-Credentials', 'true');
|
|
42
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
|
|
43
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Invalid origin URL, skip CORS headers
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
next();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Session restoration middleware
|
|
53
|
+
export async function sessionRestorationMiddleware(req, res, next) {
|
|
54
|
+
// Only restore session if not already present and sessionId cookie exists
|
|
55
|
+
if (!req.session.user && req.cookies.sessionId) {
|
|
56
|
+
const sessionId = req.cookies.sessionId;
|
|
57
|
+
|
|
58
|
+
// Early validation to avoid unnecessary processing
|
|
59
|
+
if (typeof sessionId !== 'string' || !/^[a-f0-9]{64}$/i.test(sessionId)) {
|
|
60
|
+
// Clear invalid cookie to prevent repeated attempts
|
|
61
|
+
res.clearCookie('sessionId', {
|
|
62
|
+
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
|
|
63
|
+
path: '/',
|
|
64
|
+
httpOnly: true,
|
|
65
|
+
secure: mbkautheVar.IS_DEPLOYED === 'true',
|
|
66
|
+
sameSite: 'lax'
|
|
67
|
+
});
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const normalizedSessionId = sessionId.toLowerCase();
|
|
73
|
+
|
|
74
|
+
const query = `SELECT id, "UserName", "Active", "Role", "SessionId", "AllowedApps" FROM "Users" WHERE LOWER("SessionId") = $1 AND "Active" = true`;
|
|
75
|
+
const result = await dblogin.query({ name: 'restore-user-session', text: query, values: [normalizedSessionId] });
|
|
76
|
+
|
|
77
|
+
if (result.rows.length > 0) {
|
|
78
|
+
const user = result.rows[0];
|
|
79
|
+
req.session.user = {
|
|
80
|
+
id: user.id,
|
|
81
|
+
username: user.UserName,
|
|
82
|
+
UserName: user.UserName,
|
|
83
|
+
role: user.Role,
|
|
84
|
+
Role: user.Role,
|
|
85
|
+
sessionId: normalizedSessionId,
|
|
86
|
+
allowedApps: user.AllowedApps,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error("[mbkauthe] Session restoration error:", err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
next();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Session cookie sync middleware
|
|
97
|
+
export function sessionCookieSyncMiddleware(req, res, next) {
|
|
98
|
+
if (req.session && req.session.user) {
|
|
99
|
+
// Only set cookies if they're missing or different
|
|
100
|
+
if (req.cookies.sessionId !== req.session.user.sessionId) {
|
|
101
|
+
res.cookie("username", req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
102
|
+
res.cookie("sessionId", req.session.user.sessionId, cachedCookieOptions);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
next();
|
|
106
|
+
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import csurf from "csurf";
|
|
4
|
+
import speakeasy from "speakeasy";
|
|
5
|
+
import rateLimit from 'express-rate-limit';
|
|
6
|
+
import { dblogin } from "../database/pool.js";
|
|
7
|
+
import { mbkautheVar } from "../config/index.js";
|
|
8
|
+
import {
|
|
9
|
+
cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
|
|
10
|
+
generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS
|
|
11
|
+
} from "../config/cookies.js";
|
|
12
|
+
import { packageJson } from "../config/index.js";
|
|
13
|
+
import { hashPassword } from "../config/security.js";
|
|
14
|
+
import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
|
|
15
|
+
|
|
16
|
+
const router = express.Router();
|
|
17
|
+
|
|
18
|
+
// Rate limiters for auth routes
|
|
19
|
+
const LoginLimit = rateLimit({
|
|
20
|
+
windowMs: 1 * 60 * 1000,
|
|
21
|
+
max: 8,
|
|
22
|
+
message: { success: false, message: "Too many attempts, please try again later" },
|
|
23
|
+
skip: (req) => {
|
|
24
|
+
return !!req.session.user;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const LogoutLimit = rateLimit({
|
|
29
|
+
windowMs: 1 * 60 * 1000,
|
|
30
|
+
max: 10,
|
|
31
|
+
message: { success: false, message: "Too many logout attempts, please try again later" }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const TwoFALimit = rateLimit({
|
|
35
|
+
windowMs: 1 * 60 * 1000,
|
|
36
|
+
max: 5,
|
|
37
|
+
message: { success: false, message: "Too many 2FA attempts, please try again later" }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// CSRF protection middleware
|
|
41
|
+
const csrfProtection = csurf({ cookie: true });
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if the device is trusted for the given username
|
|
45
|
+
*/
|
|
46
|
+
export async function checkTrustedDevice(req, username) {
|
|
47
|
+
const deviceToken = req.cookies.device_token;
|
|
48
|
+
|
|
49
|
+
if (!deviceToken || typeof deviceToken !== 'string') {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const deviceQuery = `
|
|
55
|
+
SELECT td."UserName", td."LastUsed", td."ExpiresAt", u."id", u."Active", u."Role", u."AllowedApps"
|
|
56
|
+
FROM "TrustedDevices" td
|
|
57
|
+
JOIN "Users" u ON td."UserName" = u."UserName"
|
|
58
|
+
WHERE td."DeviceToken" = $1 AND td."UserName" = $2 AND td."ExpiresAt" > NOW()
|
|
59
|
+
`;
|
|
60
|
+
const deviceResult = await dblogin.query({
|
|
61
|
+
name: 'check-trusted-device',
|
|
62
|
+
text: deviceQuery,
|
|
63
|
+
values: [deviceToken, username]
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (deviceResult.rows.length > 0) {
|
|
67
|
+
const deviceUser = deviceResult.rows[0];
|
|
68
|
+
|
|
69
|
+
if (!deviceUser.Active) {
|
|
70
|
+
console.log(`[mbkauthe] Trusted device check: inactive account for username: ${username}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (deviceUser.Role !== "SuperAdmin") {
|
|
75
|
+
const allowedApps = deviceUser.AllowedApps;
|
|
76
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
|
|
77
|
+
console.warn(`[mbkauthe] Trusted device check: User "${username}" is not authorized to use the application "${mbkautheVar.APP_NAME}"`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Update last used timestamp
|
|
83
|
+
await dblogin.query({
|
|
84
|
+
name: 'update-device-last-used',
|
|
85
|
+
text: 'UPDATE "TrustedDevices" SET "LastUsed" = NOW() WHERE "DeviceToken" = $1',
|
|
86
|
+
values: [deviceToken]
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
console.log(`[mbkauthe] Trusted device validated for user: ${username}`);
|
|
90
|
+
return {
|
|
91
|
+
id: deviceUser.id,
|
|
92
|
+
username: username,
|
|
93
|
+
role: deviceUser.Role,
|
|
94
|
+
Role: deviceUser.Role,
|
|
95
|
+
allowedApps: deviceUser.AllowedApps,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
} catch (deviceErr) {
|
|
99
|
+
console.error("[mbkauthe] Error checking trusted device:", deviceErr);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Complete the login process by creating session and cookies
|
|
107
|
+
*/
|
|
108
|
+
export async function completeLoginProcess(req, res, user, redirectUrl = null, trustDevice = false) {
|
|
109
|
+
try {
|
|
110
|
+
// Ensure both username formats are available for compatibility
|
|
111
|
+
const username = user.username || user.UserName;
|
|
112
|
+
if (!username) {
|
|
113
|
+
throw new Error('Username is required in user object');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// smaller session id is sufficient and faster to generate/serialize
|
|
117
|
+
const sessionId = crypto.randomBytes(32).toString("hex");
|
|
118
|
+
console.log(`[mbkauthe] Generated session ID for username: ${username}`);
|
|
119
|
+
|
|
120
|
+
// Regenerate session to prevent session fixation attacks
|
|
121
|
+
const oldSessionId = req.sessionID;
|
|
122
|
+
await new Promise((resolve, reject) => {
|
|
123
|
+
req.session.regenerate((err) => {
|
|
124
|
+
if (err) reject(err);
|
|
125
|
+
else resolve();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Run both queries in parallel for better performance
|
|
130
|
+
await Promise.all([
|
|
131
|
+
// Delete old sessions using indexed lookup on sess->'user'->>'id'
|
|
132
|
+
dblogin.query({
|
|
133
|
+
name: 'login-delete-old-user-sessions',
|
|
134
|
+
text: 'DELETE FROM "session" WHERE (sess->\'user\'->>\'id\')::int = $1',
|
|
135
|
+
values: [user.id]
|
|
136
|
+
}),
|
|
137
|
+
// Update session ID and last login time in Users table
|
|
138
|
+
dblogin.query({
|
|
139
|
+
name: 'login-update-session-and-last-login',
|
|
140
|
+
text: `UPDATE "Users" SET "SessionId" = $1, "last_login" = NOW() WHERE "id" = $2`,
|
|
141
|
+
values: [sessionId, user.id]
|
|
142
|
+
})
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
req.session.user = {
|
|
146
|
+
id: user.id,
|
|
147
|
+
username: username,
|
|
148
|
+
UserName: username,
|
|
149
|
+
role: user.role || user.Role,
|
|
150
|
+
Role: user.role || user.Role,
|
|
151
|
+
sessionId,
|
|
152
|
+
allowedApps: user.allowedApps || user.AllowedApps,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (req.session.preAuthUser) {
|
|
156
|
+
delete req.session.preAuthUser;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
req.session.save(async (err) => {
|
|
160
|
+
if (err) {
|
|
161
|
+
console.error("[mbkauthe] Session save error:", err);
|
|
162
|
+
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
res.cookie("sessionId", sessionId, cachedCookieOptions);
|
|
166
|
+
|
|
167
|
+
// Handle trusted device if requested
|
|
168
|
+
if (trustDevice) {
|
|
169
|
+
try {
|
|
170
|
+
const deviceToken = generateDeviceToken();
|
|
171
|
+
const deviceName = req.headers['user-agent'] ?
|
|
172
|
+
req.headers['user-agent'].substring(0, 255) : 'Unknown Device';
|
|
173
|
+
const userAgent = req.headers['user-agent'] || 'Unknown';
|
|
174
|
+
const ipAddress = req.ip || req.connection.remoteAddress || 'Unknown';
|
|
175
|
+
const expiresAt = new Date(Date.now() + DEVICE_TRUST_DURATION_MS);
|
|
176
|
+
|
|
177
|
+
await dblogin.query({
|
|
178
|
+
name: 'insert-trusted-device',
|
|
179
|
+
text: `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
|
|
180
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
181
|
+
values: [username, deviceToken, deviceName, userAgent, ipAddress, expiresAt]
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
res.cookie("device_token", deviceToken, getDeviceTokenCookieOptions());
|
|
185
|
+
console.log(`[mbkauthe] Trusted device token created for user: ${username}`);
|
|
186
|
+
} catch (deviceErr) {
|
|
187
|
+
console.error("[mbkauthe] Error creating trusted device:", deviceErr);
|
|
188
|
+
// Continue with login even if device trust fails
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log(`[mbkauthe] User "${username}" logged in successfully (last_login updated)`);
|
|
193
|
+
|
|
194
|
+
const responsePayload = {
|
|
195
|
+
success: true,
|
|
196
|
+
message: "Login successful",
|
|
197
|
+
sessionId,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (redirectUrl) {
|
|
201
|
+
responsePayload.redirectUrl = redirectUrl;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
res.status(200).json(responsePayload);
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.error("[mbkauthe] Error during login completion:", err);
|
|
208
|
+
res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// POST /mbkauthe/api/login
|
|
213
|
+
router.post("/api/login", LoginLimit, async (req, res) => {
|
|
214
|
+
console.log("[mbkauthe] Login request received");
|
|
215
|
+
|
|
216
|
+
const { username, password, redirect } = req.body;
|
|
217
|
+
|
|
218
|
+
// Input validation
|
|
219
|
+
if (!username || !password) {
|
|
220
|
+
logError('Login attempt', ErrorCodes.MISSING_REQUIRED_FIELD, { username: username || 'missing' });
|
|
221
|
+
return res.status(400).json(
|
|
222
|
+
createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD, {
|
|
223
|
+
message: "Username and password are required"
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Validate username format and length
|
|
229
|
+
if (typeof username !== 'string' || username.trim().length === 0 || username.length > 255) {
|
|
230
|
+
logError('Login attempt', ErrorCodes.INVALID_USERNAME_FORMAT, { username });
|
|
231
|
+
return res.status(400).json(
|
|
232
|
+
createErrorResponse(400, ErrorCodes.INVALID_USERNAME_FORMAT)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validate password length
|
|
237
|
+
if (typeof password !== 'string' || password.length < 8 || password.length > 255) {
|
|
238
|
+
logError('Login attempt', ErrorCodes.INVALID_PASSWORD_LENGTH, { username: username.trim() });
|
|
239
|
+
return res.status(400).json(
|
|
240
|
+
createErrorResponse(400, ErrorCodes.INVALID_PASSWORD_LENGTH)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.log(`[mbkauthe] Login attempt for username: ${username.trim()}`);
|
|
245
|
+
|
|
246
|
+
const trimmedUsername = username.trim();
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// Combined query: fetch user data and 2FA status in one query
|
|
250
|
+
const userQuery = `
|
|
251
|
+
SELECT u.id, u."UserName", u."Password", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
|
|
252
|
+
tfa."TwoFAStatus"
|
|
253
|
+
FROM "Users" u
|
|
254
|
+
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
255
|
+
WHERE u."UserName" = $1
|
|
256
|
+
`;
|
|
257
|
+
const userResult = await dblogin.query({ name: 'login-get-user', text: userQuery, values: [trimmedUsername] });
|
|
258
|
+
|
|
259
|
+
if (userResult.rows.length === 0) {
|
|
260
|
+
logError('Login attempt', ErrorCodes.USER_NOT_FOUND, { username: trimmedUsername });
|
|
261
|
+
return res.status(401).json(
|
|
262
|
+
createErrorResponse(401, ErrorCodes.INVALID_CREDENTIALS)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const user = userResult.rows[0];
|
|
267
|
+
|
|
268
|
+
// Validate user has password field
|
|
269
|
+
if (!user.Password && !user.PasswordEnc) {
|
|
270
|
+
console.error("[mbkauthe] User account has no password set");
|
|
271
|
+
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Check password based on EncPass configuration - ALWAYS validate password first
|
|
275
|
+
let passwordMatches = false;
|
|
276
|
+
if (mbkautheVar.EncPass === "true" || mbkautheVar.EncPass === true) {
|
|
277
|
+
// Use encrypted password comparison
|
|
278
|
+
if (user.PasswordEnc) {
|
|
279
|
+
const hashedInputPassword = hashPassword(password, user.UserName);
|
|
280
|
+
passwordMatches = user.PasswordEnc === hashedInputPassword;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
// Use raw password comparison
|
|
284
|
+
if (user.Password) {
|
|
285
|
+
passwordMatches = user.Password === password;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!passwordMatches) {
|
|
290
|
+
logError('Login attempt', ErrorCodes.INCORRECT_PASSWORD, { username: trimmedUsername });
|
|
291
|
+
return res.status(401).json(
|
|
292
|
+
createErrorResponse(401, ErrorCodes.INCORRECT_PASSWORD)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!user.Active) {
|
|
297
|
+
logError('Login attempt', ErrorCodes.ACCOUNT_INACTIVE, { username: trimmedUsername });
|
|
298
|
+
return res.status(403).json(
|
|
299
|
+
createErrorResponse(403, ErrorCodes.ACCOUNT_INACTIVE)
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (user.Role !== "SuperAdmin") {
|
|
304
|
+
const allowedApps = user.AllowedApps;
|
|
305
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
|
|
306
|
+
logError('Login attempt', ErrorCodes.APP_NOT_AUTHORIZED, {
|
|
307
|
+
username: user.UserName,
|
|
308
|
+
app: mbkautheVar.APP_NAME
|
|
309
|
+
});
|
|
310
|
+
return res.status(403).json(
|
|
311
|
+
createErrorResponse(403, ErrorCodes.APP_NOT_AUTHORIZED, {
|
|
312
|
+
message: `You are not authorized to access ${mbkautheVar.APP_NAME}`,
|
|
313
|
+
app: mbkautheVar.APP_NAME
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Check for trusted device AFTER password validation
|
|
320
|
+
const trustedDeviceUser = await checkTrustedDevice(req, trimmedUsername);
|
|
321
|
+
if (trustedDeviceUser && (mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) {
|
|
322
|
+
console.log(`[mbkauthe] Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
|
|
323
|
+
|
|
324
|
+
const userForSession = {
|
|
325
|
+
id: user.id,
|
|
326
|
+
username: user.UserName,
|
|
327
|
+
role: user.Role,
|
|
328
|
+
Role: user.Role,
|
|
329
|
+
allowedApps: user.AllowedApps,
|
|
330
|
+
};
|
|
331
|
+
const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
|
|
332
|
+
return await completeLoginProcess(req, res, userForSession, requestedRedirect);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if ((mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) {
|
|
336
|
+
// 2FA is enabled, prompt for token on a separate page
|
|
337
|
+
const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
|
|
338
|
+
req.session.preAuthUser = {
|
|
339
|
+
id: user.id,
|
|
340
|
+
username: user.UserName,
|
|
341
|
+
role: user.Role,
|
|
342
|
+
Role: user.Role,
|
|
343
|
+
redirectUrl: requestedRedirect
|
|
344
|
+
};
|
|
345
|
+
console.log(`[mbkauthe] 2FA required for user: ${trimmedUsername}`);
|
|
346
|
+
return res.json({ success: true, twoFactorRequired: true, redirectUrl: requestedRedirect });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// If 2FA is not enabled, proceed with login
|
|
350
|
+
const userForSession = {
|
|
351
|
+
id: user.id,
|
|
352
|
+
username: user.UserName,
|
|
353
|
+
role: user.Role,
|
|
354
|
+
Role: user.Role,
|
|
355
|
+
allowedApps: user.AllowedApps,
|
|
356
|
+
};
|
|
357
|
+
const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
|
|
358
|
+
await completeLoginProcess(req, res, userForSession, requestedRedirect);
|
|
359
|
+
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error("[mbkauthe] Error during login process:", err);
|
|
362
|
+
res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// GET /mbkauthe/2fa
|
|
367
|
+
router.get("/2fa", csrfProtection, (req, res) => {
|
|
368
|
+
if (!req.session.preAuthUser) {
|
|
369
|
+
return res.redirect("/mbkauthe/login");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Prefer explicit redirect from query string, else from session preAuthUser redirectUrl, else fallback value
|
|
373
|
+
let redirectFromQuery = req.query && typeof req.query.redirect === 'string' ? req.query.redirect : null;
|
|
374
|
+
let redirectToUse = redirectFromQuery || req.session.preAuthUser.redirectUrl || (mbkautheVar.loginRedirectURL || '/dashboard');
|
|
375
|
+
|
|
376
|
+
// Validate redirectToUse to prevent open redirect attacks
|
|
377
|
+
if (redirectToUse && !(typeof redirectToUse === 'string' && redirectToUse.startsWith('/') && !redirectToUse.startsWith('//'))) {
|
|
378
|
+
redirectToUse = mbkautheVar.loginRedirectURL || '/dashboard';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
res.render("2fa.handlebars", {
|
|
382
|
+
layout: false,
|
|
383
|
+
customURL: redirectToUse,
|
|
384
|
+
csrfToken: req.csrfToken(),
|
|
385
|
+
appName: mbkautheVar.APP_NAME.toLowerCase(),
|
|
386
|
+
version: packageJson.version,
|
|
387
|
+
DEVICE_TRUST_DURATION_DAYS: mbkautheVar.DEVICE_TRUST_DURATION_DAYS
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// POST /mbkauthe/api/verify-2fa
|
|
392
|
+
router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
393
|
+
if (!req.session.preAuthUser) {
|
|
394
|
+
return res.status(401).json(
|
|
395
|
+
createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND, {
|
|
396
|
+
message: "Please log in first"
|
|
397
|
+
})
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const { token, trustDevice } = req.body;
|
|
402
|
+
const { username, id, role } = req.session.preAuthUser;
|
|
403
|
+
|
|
404
|
+
// Validate 2FA token
|
|
405
|
+
if (!token || typeof token !== 'string') {
|
|
406
|
+
return res.status(400).json(
|
|
407
|
+
createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD, {
|
|
408
|
+
message: "2FA token is required"
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Validate token format (should be 6 digits)
|
|
414
|
+
const sanitizedToken = token.trim();
|
|
415
|
+
if (!/^\d{6}$/.test(sanitizedToken)) {
|
|
416
|
+
return res.status(400).json(
|
|
417
|
+
createErrorResponse(400, ErrorCodes.INVALID_TOKEN_FORMAT)
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Validate trustDevice parameter if provided
|
|
422
|
+
const shouldTrustDevice = trustDevice === true || trustDevice === 'true';
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const query = `SELECT tfa."TwoFASecret", u."AllowedApps" FROM "TwoFA" tfa JOIN "Users" u ON tfa."UserName" = u."UserName" WHERE tfa."UserName" = $1`;
|
|
426
|
+
const twoFAResult = await dblogin.query({ name: 'verify-2fa-secret', text: query, values: [username] });
|
|
427
|
+
|
|
428
|
+
if (twoFAResult.rows.length === 0 || !twoFAResult.rows[0].TwoFASecret) {
|
|
429
|
+
return res.status(500).json(
|
|
430
|
+
createErrorResponse(500, ErrorCodes.TWO_FA_NOT_CONFIGURED)
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const sharedSecret = twoFAResult.rows[0].TwoFASecret;
|
|
435
|
+
const allowedApps = twoFAResult.rows[0].AllowedApps;
|
|
436
|
+
const tokenValidates = speakeasy.totp.verify({
|
|
437
|
+
secret: sharedSecret,
|
|
438
|
+
encoding: "base32",
|
|
439
|
+
token: sanitizedToken,
|
|
440
|
+
window: 1,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (!tokenValidates) {
|
|
444
|
+
logError('2FA verification', ErrorCodes.TWO_FA_INVALID_TOKEN, { username });
|
|
445
|
+
return res.status(401).json(
|
|
446
|
+
createErrorResponse(401, ErrorCodes.TWO_FA_INVALID_TOKEN)
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 2FA successful, complete login with optional device trust
|
|
451
|
+
const userForSession = { id, username, role, allowedApps };
|
|
452
|
+
// Prefer redirect stored in preAuthUser or in query/body, fallback to configured default
|
|
453
|
+
let redirectFromSession = req.session.preAuthUser && req.session.preAuthUser.redirectUrl ? req.session.preAuthUser.redirectUrl : null;
|
|
454
|
+
if (redirectFromSession && (!(typeof redirectFromSession === 'string') || !redirectFromSession.startsWith('/') || redirectFromSession.startsWith('//'))) {
|
|
455
|
+
redirectFromSession = null;
|
|
456
|
+
}
|
|
457
|
+
const redirectUrl = redirectFromSession || mbkautheVar.loginRedirectURL || '/dashboard';
|
|
458
|
+
// Clear preAuthUser after successful login
|
|
459
|
+
if (req.session.preAuthUser) delete req.session.preAuthUser;
|
|
460
|
+
await completeLoginProcess(req, res, userForSession, redirectUrl, shouldTrustDevice);
|
|
461
|
+
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error("[mbkauthe] Error during 2FA verification:", err);
|
|
464
|
+
res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// POST /mbkauthe/api/logout
|
|
469
|
+
router.post("/api/logout", LogoutLimit, async (req, res) => {
|
|
470
|
+
if (req.session.user) {
|
|
471
|
+
try {
|
|
472
|
+
const { id, username } = req.session.user;
|
|
473
|
+
|
|
474
|
+
// Run both database operations in parallel
|
|
475
|
+
const operations = [
|
|
476
|
+
dblogin.query({ name: 'logout-clear-session', text: `UPDATE "Users" SET "SessionId" = NULL WHERE "id" = $1`, values: [id] })
|
|
477
|
+
];
|
|
478
|
+
|
|
479
|
+
if (req.sessionID) {
|
|
480
|
+
operations.push(
|
|
481
|
+
dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] })
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await Promise.all(operations);
|
|
486
|
+
|
|
487
|
+
req.session.destroy((err) => {
|
|
488
|
+
if (err) {
|
|
489
|
+
console.error("[mbkauthe] Error destroying session:", err);
|
|
490
|
+
return res.status(500).json({ success: false, message: "Logout failed" });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
clearSessionCookies(res);
|
|
494
|
+
|
|
495
|
+
console.log(`[mbkauthe] User "${username}" logged out successfully`);
|
|
496
|
+
res.status(200).json({ success: true, message: "Logout successful" });
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error("[mbkauthe] Database query error during logout:", err);
|
|
500
|
+
res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
res.status(400).json({ success: false, message: "Not logged in" });
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// GET /mbkauthe/login
|
|
508
|
+
router.get("/login", LoginLimit, csrfProtection, (req, res) => {
|
|
509
|
+
return res.render("loginmbkauthe.handlebars", {
|
|
510
|
+
layout: false,
|
|
511
|
+
githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
|
|
512
|
+
customURL: mbkautheVar.loginRedirectURL || '/dashboard',
|
|
513
|
+
userLoggedIn: !!req.session?.user,
|
|
514
|
+
username: req.session?.user?.username || '',
|
|
515
|
+
version: packageJson.version,
|
|
516
|
+
appName: mbkautheVar.APP_NAME.toLowerCase(),
|
|
517
|
+
csrfToken: req.csrfToken(),
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
export default router;
|