mbkauthe 5.0.4 → 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/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 +7 -3
- 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 +4 -3
- package/lib/middleware/index.js +19 -15
- package/lib/routes/auth.js +14 -19
- package/lib/routes/misc.js +32 -7
- package/lib/routes/oauth.js +4 -4
- 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 +1 -1
- package/public/main.js +13 -1
- package/test.spec.js +73 -1
- package/views/pages/accountSwitch.handlebars +11 -11
- package/views/pages/loginmbkauthe.handlebars +32 -24
- package/views/pages/test.handlebars +1 -1
|
@@ -35,7 +35,7 @@ This document describes the environment variables MBKAuth expects and keeps brie
|
|
|
35
35
|
- Required: Yes
|
|
36
36
|
|
|
37
37
|
- DOMAIN
|
|
38
|
-
- Description:
|
|
38
|
+
- Description: Root app domain used for cross-subdomain cookie sharing in production (e.g., `yourdomain.com`, not `auth.yourdomain.com`). When `IS_DEPLOYED=true`, session cookies are scoped to `.yourdomain.com` so they are sent on all subdomains.
|
|
39
39
|
- Example: `"DOMAIN":"localhost"`
|
|
40
40
|
- Required: Yes
|
|
41
41
|
|
|
@@ -95,11 +95,6 @@ This document describes the environment variables MBKAuth expects and keeps brie
|
|
|
95
95
|
- If `GOOGLE_LOGIN_ENABLED=true`, `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are required.
|
|
96
96
|
- If `GITHUB_LOGIN_ENABLED=true`, GitHub App client credentials are required.
|
|
97
97
|
|
|
98
|
-
- GITHUB_APP_SLUG
|
|
99
|
-
- Description: GitHub App slug (optional for login flow in this package; useful for install/link flows handled elsewhere).
|
|
100
|
-
- Required: No
|
|
101
|
-
- Create GitHub App: https://github.com/settings/apps
|
|
102
|
-
|
|
103
98
|
- GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET
|
|
104
99
|
- Description: GitHub App OAuth credentials used for user sign-in.
|
|
105
100
|
- Required when `GITHUB_LOGIN_ENABLED=true`.
|
package/docs/guides/database.md
CHANGED
|
@@ -16,7 +16,7 @@ Postgres enum `role`: `SuperAdmin`, `NormalUser`, `Guest`, `member`. The script
|
|
|
16
16
|
|
|
17
17
|
Core accounts table (`"Users"`): username, activation flag, role, mail flag, `AllowedApps` and `Positions` as JSONB, timestamps, optional `last_login`, and password hash column `PasswordEnc` (no plaintext passwords).
|
|
18
18
|
|
|
19
|
-
Profile-style columns include `FullName`, `email`, `Image`, `Bio`, `SocialAccounts`, and password-reset fields (`resetToken`, `resetTokenExpires`, `resetAttempts`, `lastResetAttempt`).
|
|
19
|
+
Profile-style columns include `FullName`, `UserId` (exactly 9 characters, unique business identifier assigned externally), `email`, `Image`, `Bio`, `SocialAccounts`, and password-reset fields (`resetToken`, `resetTokenExpires`, `resetAttempts`, `lastResetAttempt`).
|
|
20
20
|
|
|
21
21
|
Indexes cover username, role, active, email, last login, and GIN indexes on JSONB for `AllowedApps` and `Positions`. The SQL file also adds optional covering indexes used on hot auth paths.
|
|
22
22
|
|
package/docs/schema/db.sql
CHANGED
|
@@ -17,13 +17,16 @@ CREATE TABLE IF NOT EXISTS "Users" (
|
|
|
17
17
|
last_login timestamp with time zone,
|
|
18
18
|
"PasswordEnc" character varying(255),
|
|
19
19
|
"FullName" character varying(100),
|
|
20
|
+
"UserId" character varying(9),
|
|
20
21
|
email text DEFAULT 'support@mbktech.org'::text,
|
|
21
22
|
"Image" text DEFAULT 'https://portal.mbktech.org/icon.svg'::text,
|
|
22
23
|
"Bio" text DEFAULT 'I am ....'::text,
|
|
23
24
|
"SocialAccounts" text DEFAULT '{}'::text,
|
|
24
25
|
"Positions" jsonb DEFAULT '{"Not_Permanent": "Member Is Not Permanent"}'::jsonb,
|
|
25
26
|
CONSTRAINT "Users_pkey" PRIMARY KEY (id),
|
|
26
|
-
CONSTRAINT "Users_UserName_key" UNIQUE ("UserName")
|
|
27
|
+
CONSTRAINT "Users_UserName_key" UNIQUE ("UserName"),
|
|
28
|
+
CONSTRAINT "Users_UserId_key" UNIQUE ("UserId"),
|
|
29
|
+
CONSTRAINT chk_users_userid_length CHECK ("UserId" IS NULL OR length("UserId") = 9)
|
|
27
30
|
);
|
|
28
31
|
CREATE INDEX IF NOT EXISTS idx_users_active ON "Users" USING btree ("Active");
|
|
29
32
|
CREATE INDEX IF NOT EXISTS idx_users_allowedapps_gin ON "Users" USING gin ("AllowedApps");
|
|
@@ -32,6 +35,7 @@ CREATE INDEX IF NOT EXISTS idx_users_last_login ON "Users" USING btree (last_log
|
|
|
32
35
|
CREATE INDEX IF NOT EXISTS idx_users_positions_gin ON "Users" USING gin ("Positions");
|
|
33
36
|
CREATE INDEX IF NOT EXISTS idx_users_role ON "Users" USING btree ("Role");
|
|
34
37
|
CREATE INDEX IF NOT EXISTS idx_users_username_cover ON "Users" USING btree ("UserName") INCLUDE ("Active", "Role");
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_users_userid ON "Users" USING btree ("UserId");
|
|
35
39
|
COMMENT ON TABLE "Users" IS '[core]';
|
|
36
40
|
|
|
37
41
|
-- Table: ApiTokens
|
|
@@ -326,3 +330,20 @@ COMMENT ON TABLE "website_access_logs" IS '[core]';
|
|
|
326
330
|
INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "FullName")
|
|
327
331
|
VALUES ('support', 'b8b10c1c9006d8c30ab81c412463c65ff6dae3293d9bfbaf5fd8e275081d0947f000a828004e2fbd3a8f6ef5a35ae3eddd4c57b00ecab376b12e607a16a57459', 'SuperAdmin', true, false, 'Support User')
|
|
328
332
|
ON CONFLICT ("UserName") DO NOTHING;
|
|
333
|
+
|
|
334
|
+
-- Migration: add UserId to existing Users tables (idempotent)
|
|
335
|
+
ALTER TABLE "Users" ADD COLUMN IF NOT EXISTS "UserId" character varying(9);
|
|
336
|
+
|
|
337
|
+
DO $$
|
|
338
|
+
BEGIN
|
|
339
|
+
IF NOT EXISTS (
|
|
340
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'Users_UserId_key'
|
|
341
|
+
) THEN
|
|
342
|
+
ALTER TABLE "Users" ADD CONSTRAINT "Users_UserId_key" UNIQUE ("UserId");
|
|
343
|
+
END IF;
|
|
344
|
+
END $$;
|
|
345
|
+
|
|
346
|
+
ALTER TABLE "Users" DROP CONSTRAINT IF EXISTS chk_users_userid_length;
|
|
347
|
+
ALTER TABLE "Users" ADD CONSTRAINT chk_users_userid_length CHECK ("UserId" IS NULL OR length("UserId") = 9);
|
|
348
|
+
|
|
349
|
+
CREATE INDEX IF NOT EXISTS idx_users_userid ON "Users" USING btree ("UserId");
|
package/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ declare global {
|
|
|
19
19
|
|
|
20
20
|
interface Session {
|
|
21
21
|
user?: {
|
|
22
|
-
|
|
22
|
+
userId?: string;
|
|
23
23
|
username: string;
|
|
24
24
|
fullname?: string;
|
|
25
25
|
role: 'SuperAdmin' | 'NormalUser' | 'Guest';
|
|
@@ -28,7 +28,7 @@ declare global {
|
|
|
28
28
|
tokenScope?: 'read-only' | 'write' | null;
|
|
29
29
|
};
|
|
30
30
|
preAuthUser?: {
|
|
31
|
-
|
|
31
|
+
userId?: string;
|
|
32
32
|
username: string;
|
|
33
33
|
role: 'SuperAdmin' | 'NormalUser' | 'Guest';
|
|
34
34
|
loginMethod?: 'password' | 'github' | 'google';
|
|
@@ -55,7 +55,6 @@ declare module 'mbkauthe' {
|
|
|
55
55
|
COOKIE_EXPIRE_TIME?: number;
|
|
56
56
|
DEVICE_TRUST_DURATION_DAYS?: number;
|
|
57
57
|
GITHUB_LOGIN_ENABLED?: 'true' | 'false' | 'f';
|
|
58
|
-
GITHUB_APP_SLUG?: string;
|
|
59
58
|
GITHUB_APP_CLIENT_ID?: string;
|
|
60
59
|
GITHUB_APP_CLIENT_SECRET?: string;
|
|
61
60
|
GOOGLE_LOGIN_ENABLED?: 'true' | 'false' | 'f';
|
|
@@ -67,7 +66,6 @@ declare module 'mbkauthe' {
|
|
|
67
66
|
|
|
68
67
|
export interface OAuthConfig {
|
|
69
68
|
GITHUB_LOGIN_ENABLED?: 'true' | 'false' | 'f';
|
|
70
|
-
GITHUB_APP_SLUG?: string;
|
|
71
69
|
GITHUB_APP_CLIENT_ID?: string;
|
|
72
70
|
GITHUB_APP_CLIENT_SECRET?: string;
|
|
73
71
|
GOOGLE_LOGIN_ENABLED?: 'true' | 'false' | 'f';
|
|
@@ -79,7 +77,7 @@ declare module 'mbkauthe' {
|
|
|
79
77
|
export type UserRole = 'SuperAdmin' | 'NormalUser' | 'Guest';
|
|
80
78
|
|
|
81
79
|
export interface SessionUser {
|
|
82
|
-
|
|
80
|
+
userId?: string;
|
|
83
81
|
username: string;
|
|
84
82
|
fullname?: string;
|
|
85
83
|
role: UserRole;
|
|
@@ -88,7 +86,7 @@ declare module 'mbkauthe' {
|
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
export interface PreAuthUser {
|
|
91
|
-
|
|
89
|
+
userId?: string;
|
|
92
90
|
username: string;
|
|
93
91
|
role: UserRole;
|
|
94
92
|
allowedApps?: string[];
|
|
@@ -100,6 +98,7 @@ declare module 'mbkauthe' {
|
|
|
100
98
|
export interface DBUser {
|
|
101
99
|
id: number;
|
|
102
100
|
UserName: string;
|
|
101
|
+
UserId?: string;
|
|
103
102
|
PasswordEnc?: string;
|
|
104
103
|
Role: UserRole;
|
|
105
104
|
Active: boolean;
|
|
@@ -300,6 +299,18 @@ declare module 'mbkauthe' {
|
|
|
300
299
|
httpOnly: boolean;
|
|
301
300
|
};
|
|
302
301
|
|
|
302
|
+
export function resolveCookieDomain(
|
|
303
|
+
isDeployed: 'true' | 'false' | 'f' | string,
|
|
304
|
+
domain?: string,
|
|
305
|
+
isTestDev?: boolean
|
|
306
|
+
): string | undefined;
|
|
307
|
+
|
|
308
|
+
export function getCookieDomain(): string | undefined;
|
|
309
|
+
|
|
310
|
+
export function getCookieSecure(): boolean;
|
|
311
|
+
|
|
312
|
+
export function isAllowedOriginHostname(hostname: string, domain?: string): boolean;
|
|
313
|
+
|
|
303
314
|
export function getClearCookieOptions(): {
|
|
304
315
|
domain?: string;
|
|
305
316
|
secure: boolean;
|
|
@@ -321,6 +332,8 @@ declare module 'mbkauthe' {
|
|
|
321
332
|
|
|
322
333
|
export function hashPassword(password: string, username: string): string;
|
|
323
334
|
|
|
335
|
+
export function verifyPassword(password: string, username: string, storedHash: string): Promise<boolean>;
|
|
336
|
+
|
|
324
337
|
export function clearSessionCookies(res: Response): void;
|
|
325
338
|
|
|
326
339
|
export function getLatestVersion(): Promise<string>;
|
package/lib/config/cookies.js
CHANGED
|
@@ -6,7 +6,7 @@ const MAX_REMEMBERED_ACCOUNTS = 5;
|
|
|
6
6
|
const ACCOUNT_LIST_COOKIE = 'mbkauthe_accounts';
|
|
7
7
|
|
|
8
8
|
// Cookie security: encryption and signing
|
|
9
|
-
const COOKIE_ENCRYPTION_KEY = mbkautheVar.SESSION_SECRET_KEY
|
|
9
|
+
const COOKIE_ENCRYPTION_KEY = mbkautheVar.SESSION_SECRET_KEY;
|
|
10
10
|
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
|
11
11
|
|
|
12
12
|
// Derive encryption key from session secret
|
|
@@ -121,8 +121,7 @@ const decryptCookiePayload = (payload) => {
|
|
|
121
121
|
// Generate fingerprint from user-agent only (salted)
|
|
122
122
|
const generateFingerprint = (req) => {
|
|
123
123
|
const userAgent = req.headers['user-agent'] || '';
|
|
124
|
-
|
|
125
|
-
const salt = mbkautheVar.SESSION_SECRET_KEY || COOKIE_ENCRYPTION_KEY;
|
|
124
|
+
const salt = mbkautheVar.SESSION_SECRET_KEY;
|
|
126
125
|
|
|
127
126
|
// Hash user-agent with salt to prevent rainbow table attacks on UAs
|
|
128
127
|
return crypto
|
|
@@ -152,19 +151,41 @@ export const decryptSessionId = (encryptedSessionId) => {
|
|
|
152
151
|
}
|
|
153
152
|
};
|
|
154
153
|
|
|
154
|
+
const isTestDevEnvironment = () => process.env.test === 'dev';
|
|
155
|
+
|
|
156
|
+
export const resolveCookieDomain = (isDeployed, domain, isTestDev = isTestDevEnvironment()) => {
|
|
157
|
+
if (isDeployed !== 'true' || isTestDev || !domain) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `.${String(domain).replace(/^\.+/, '')}`;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const getCookieDomain = () => resolveCookieDomain(mbkautheVar.IS_DEPLOYED, mbkautheVar.DOMAIN);
|
|
165
|
+
|
|
166
|
+
export const getCookieSecure = () => mbkautheVar.IS_DEPLOYED === 'true' && !isTestDevEnvironment();
|
|
167
|
+
|
|
168
|
+
export const isAllowedOriginHostname = (hostname, domain = mbkautheVar.DOMAIN) => {
|
|
169
|
+
if (!hostname || !domain) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
174
|
+
};
|
|
175
|
+
|
|
155
176
|
// Shared cookie options functions
|
|
156
177
|
const getCookieOptions = () => ({
|
|
157
178
|
maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
|
|
158
|
-
domain:
|
|
159
|
-
secure:
|
|
179
|
+
domain: getCookieDomain(),
|
|
180
|
+
secure: getCookieSecure(),
|
|
160
181
|
sameSite: 'lax',
|
|
161
182
|
path: '/',
|
|
162
183
|
httpOnly: true
|
|
163
184
|
});
|
|
164
185
|
|
|
165
186
|
const getClearCookieOptions = () => ({
|
|
166
|
-
domain:
|
|
167
|
-
secure:
|
|
187
|
+
domain: getCookieDomain(),
|
|
188
|
+
secure: getCookieSecure(),
|
|
168
189
|
sameSite: 'lax',
|
|
169
190
|
path: '/',
|
|
170
191
|
httpOnly: true
|
|
@@ -183,16 +204,20 @@ export const generateDeviceToken = () => {
|
|
|
183
204
|
return crypto.randomBytes(32).toString('hex');
|
|
184
205
|
};
|
|
185
206
|
|
|
207
|
+
const getDeviceTokenKey = () => {
|
|
208
|
+
return crypto.createHash('sha256').update(`${mbkautheVar.SESSION_SECRET_KEY}:device-token`).digest();
|
|
209
|
+
};
|
|
210
|
+
|
|
186
211
|
// Hash a device token for safe storage in the database
|
|
187
212
|
export const hashDeviceToken = (token) => {
|
|
188
213
|
if (!token || typeof token !== 'string') return null;
|
|
189
|
-
return crypto.createHmac('sha256').update(token).digest('hex');
|
|
214
|
+
return crypto.createHmac('sha256', getDeviceTokenKey()).update(token).digest('hex');
|
|
190
215
|
};
|
|
191
216
|
|
|
192
217
|
export const getDeviceTokenCookieOptions = () => ({
|
|
193
218
|
maxAge: DEVICE_TRUST_DURATION_MS,
|
|
194
|
-
domain:
|
|
195
|
-
secure:
|
|
219
|
+
domain: getCookieDomain(),
|
|
220
|
+
secure: getCookieSecure(),
|
|
196
221
|
sameSite: 'lax',
|
|
197
222
|
path: '/',
|
|
198
223
|
httpOnly: true
|
package/lib/config/index.js
CHANGED
|
@@ -8,7 +8,7 @@ const logConfig = createLogger("config");
|
|
|
8
8
|
const CONFIG_KEYS = [
|
|
9
9
|
"APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
|
|
10
10
|
"IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
|
|
11
|
-
"GITHUB_LOGIN_ENABLED", "
|
|
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
12
|
"GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
|
|
13
13
|
];
|
|
14
14
|
|
|
@@ -26,7 +26,7 @@ const DEFAULT_CONFIG = {
|
|
|
26
26
|
const BOOLEAN_KEYS = ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'];
|
|
27
27
|
const STRING_KEYS = [
|
|
28
28
|
"APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "LOGIN_DB", "DOMAIN", "loginRedirectURL",
|
|
29
|
-
"
|
|
29
|
+
"GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
|
|
30
30
|
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"
|
|
31
31
|
];
|
|
32
32
|
const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED", "LOGIN_DB",
|
|
@@ -253,6 +253,10 @@ try {
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
import { setPasswordPepper } from "./security.js";
|
|
257
|
+
|
|
258
|
+
setPasswordPepper(mbkautheVar.SESSION_SECRET_KEY);
|
|
259
|
+
|
|
256
260
|
export { packageJson, appVersion, mbkautheVar };
|
|
257
|
-
export { hashPassword, hashApiToken } from "./security.js";
|
|
261
|
+
export { hashPassword, hashApiToken, verifyPassword } from "./security.js";
|
|
258
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
|
@@ -110,7 +110,7 @@ async function validateTokenAuthentication(req) {
|
|
|
110
110
|
updateApiTokenLastUsedThrottled(row.id);
|
|
111
111
|
|
|
112
112
|
return {
|
|
113
|
-
|
|
113
|
+
userId: row.UserId || undefined,
|
|
114
114
|
username: row.UserName,
|
|
115
115
|
fullname: row.FullName,
|
|
116
116
|
role: row.Role,
|
|
@@ -127,7 +127,7 @@ async function validateTokenAuthentication(req) {
|
|
|
127
127
|
|
|
128
128
|
function attachApiTokenUser(req, res, tokenUser) {
|
|
129
129
|
const user = {
|
|
130
|
-
|
|
130
|
+
userId: tokenUser.userId,
|
|
131
131
|
username: tokenUser.username,
|
|
132
132
|
fullname: tokenUser.fullname,
|
|
133
133
|
role: tokenUser.role,
|
|
@@ -398,7 +398,7 @@ async function validateApiSession(req, res, next) {
|
|
|
398
398
|
* Returns: true if session refreshed and valid, false if session invalidated
|
|
399
399
|
*/
|
|
400
400
|
async function reloadSessionUser(req, res) {
|
|
401
|
-
if (!req.session || !req.session.user || !req.session.user.
|
|
401
|
+
if (!req.session || !req.session.user || !req.session.user.username) return false;
|
|
402
402
|
try {
|
|
403
403
|
const { sessionId: currentSessionId } = req.session.user;
|
|
404
404
|
|
|
@@ -447,6 +447,7 @@ async function reloadSessionUser(req, res) {
|
|
|
447
447
|
req.session.user.username = row.UserName;
|
|
448
448
|
req.session.user.role = row.Role;
|
|
449
449
|
req.session.user.allowedApps = row.AllowedApps;
|
|
450
|
+
req.session.user.userId = row.UserId || undefined;
|
|
450
451
|
|
|
451
452
|
// Obtain fullname from client cookie cache when present else DB
|
|
452
453
|
if (typeof row.FullName === 'string' && row.FullName.trim() !== '') {
|
package/lib/middleware/index.js
CHANGED
|
@@ -3,8 +3,9 @@ import pgSession from "connect-pg-simple";
|
|
|
3
3
|
const PgSession = pgSession(session);
|
|
4
4
|
import { dblogin, runWithRequestContext } from "#pool.js";
|
|
5
5
|
import { mbkautheVar } from "#config.js";
|
|
6
|
-
import { cachedCookieOptions, decryptSessionId, encryptSessionId } from "#cookies.js";
|
|
6
|
+
import { cachedCookieOptions, decryptSessionId, encryptSessionId, cachedClearCookieOptions, getCookieDomain, getCookieSecure, isAllowedOriginHostname } from "#cookies.js";
|
|
7
7
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
8
|
+
import { isUserAuthorizedForApp } from "../utils/appAccess.js";
|
|
8
9
|
|
|
9
10
|
// Session configuration
|
|
10
11
|
export const sessionConfig = {
|
|
@@ -24,10 +25,10 @@ export const sessionConfig = {
|
|
|
24
25
|
cookie: {
|
|
25
26
|
maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
|
|
26
27
|
// Don't set domain in development/localhost to avoid cookie issues
|
|
27
|
-
domain: (
|
|
28
|
+
domain: getCookieDomain(),
|
|
28
29
|
httpOnly: true,
|
|
29
30
|
// Only use secure cookies in production with HTTPS
|
|
30
|
-
secure:
|
|
31
|
+
secure: getCookieSecure(),
|
|
31
32
|
sameSite: 'lax',
|
|
32
33
|
path: '/'
|
|
33
34
|
},
|
|
@@ -37,16 +38,23 @@ export const sessionConfig = {
|
|
|
37
38
|
const authRepo = new AuthRepository({ db: dblogin });
|
|
38
39
|
const hasAuthorizationHeader = (req) => typeof req.headers?.authorization === 'string' && req.headers.authorization.trim().length > 0;
|
|
39
40
|
|
|
41
|
+
export function securityHeadersMiddleware(req, res, next) {
|
|
42
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
43
|
+
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
44
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
45
|
+
if (getCookieSecure()) {
|
|
46
|
+
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
47
|
+
}
|
|
48
|
+
next();
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
// CORS middleware
|
|
41
52
|
export function corsMiddleware(req, res, next) {
|
|
42
53
|
const origin = req.headers.origin;
|
|
43
54
|
if (origin) {
|
|
44
55
|
try {
|
|
45
56
|
const originUrl = new URL(origin);
|
|
46
|
-
|
|
47
|
-
// Exact match or subdomain match
|
|
48
|
-
if (originUrl.hostname === mbkautheVar.DOMAIN ||
|
|
49
|
-
(originUrl.hostname.endsWith(allowedDomain) && originUrl.hostname.charAt(originUrl.hostname.length - allowedDomain.length - 1) !== '.')) {
|
|
57
|
+
if (isAllowedOriginHostname(originUrl.hostname)) {
|
|
50
58
|
res.header('Access-Control-Allow-Origin', origin);
|
|
51
59
|
res.header('Access-Control-Allow-Credentials', 'true');
|
|
52
60
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
|
|
@@ -73,13 +81,7 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
73
81
|
// Early validation to avoid unnecessary processing (expect DB UUID id)
|
|
74
82
|
if (!sessionId || typeof sessionId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
|
|
75
83
|
// Clear invalid cookie to prevent repeated attempts
|
|
76
|
-
res.clearCookie('sessionId',
|
|
77
|
-
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
|
|
78
|
-
path: '/',
|
|
79
|
-
httpOnly: true,
|
|
80
|
-
secure: mbkautheVar.IS_DEPLOYED === 'true',
|
|
81
|
-
sameSite: 'lax'
|
|
82
|
-
});
|
|
84
|
+
res.clearCookie('sessionId', cachedClearCookieOptions);
|
|
83
85
|
return next();
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -91,10 +93,12 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
91
93
|
// Reject expired sessions or inactive users
|
|
92
94
|
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
|
|
93
95
|
// leave cookies cleared and don't restore session
|
|
96
|
+
} else if (!isUserAuthorizedForApp(row.Role, row.AllowedApps)) {
|
|
97
|
+
// Session exists but user is not authorized for this app
|
|
94
98
|
} else {
|
|
95
99
|
const normalizedSessionId = String(sessionId);
|
|
96
100
|
req.session.user = {
|
|
97
|
-
|
|
101
|
+
userId: row.UserId || undefined,
|
|
98
102
|
username: row.UserName,
|
|
99
103
|
role: row.Role,
|
|
100
104
|
sessionId: normalizedSessionId,
|
package/lib/routes/auth.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import crypto from "crypto";
|
|
3
2
|
import csurf from "csurf";
|
|
4
3
|
import speakeasy from "speakeasy";
|
|
5
4
|
import rateLimit from 'express-rate-limit';
|
|
@@ -9,10 +8,10 @@ import {
|
|
|
9
8
|
cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
|
|
10
9
|
generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS, hashDeviceToken,
|
|
11
10
|
upsertAccountListCookie, readAccountListFromCookie, removeAccountFromCookie, clearAccountListCookie,
|
|
12
|
-
encryptSessionId
|
|
11
|
+
encryptSessionId, getCookieDomain
|
|
13
12
|
} from "#cookies.js";
|
|
14
13
|
import { packageJson } from "#config.js";
|
|
15
|
-
import { hashPassword } from "#config.js";
|
|
14
|
+
import { hashPassword, verifyPassword } from "#config.js";
|
|
16
15
|
import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
|
|
17
16
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
18
17
|
import { createLogger } from "../utils/logger.js";
|
|
@@ -109,7 +108,6 @@ export async function checkTrustedDevice(req, username) {
|
|
|
109
108
|
try {
|
|
110
109
|
// Hash the provided device token before querying DB (we store token hashes in DB)
|
|
111
110
|
const deviceTokenHash = hashDeviceToken(deviceToken);
|
|
112
|
-
// Single round-trip: validate trusted device AND refresh LastUsed.
|
|
113
111
|
const deviceUser = await authRepo.touchTrustedDevice(deviceTokenHash, username);
|
|
114
112
|
|
|
115
113
|
if (deviceUser) {
|
|
@@ -129,7 +127,7 @@ export async function checkTrustedDevice(req, username) {
|
|
|
129
127
|
|
|
130
128
|
logAuth(`Trusted device validated for user: ${username}`);
|
|
131
129
|
return {
|
|
132
|
-
|
|
130
|
+
userId: deviceUser.UserId || undefined,
|
|
133
131
|
username: username,
|
|
134
132
|
role: deviceUser.Role,
|
|
135
133
|
allowedApps: deviceUser.AllowedApps,
|
|
@@ -205,13 +203,13 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
205
203
|
// Update last_login and fetch FullName/Image in a single query.
|
|
206
204
|
let profileRow = null;
|
|
207
205
|
try {
|
|
208
|
-
profileRow = await authRepo.updateLastLoginReturnProfile(
|
|
206
|
+
profileRow = await authRepo.updateLastLoginReturnProfile(username);
|
|
209
207
|
} catch (profileUpdateErr) {
|
|
210
208
|
console.error(`[mbkauthe] Error updating last_login/returning profile:`, profileUpdateErr);
|
|
211
209
|
}
|
|
212
210
|
|
|
213
211
|
req.session.user = {
|
|
214
|
-
|
|
212
|
+
userId: user.userId || user.UserId || undefined,
|
|
215
213
|
username: username,
|
|
216
214
|
role: user.role || user.Role,
|
|
217
215
|
sessionId: dbSessionId,
|
|
@@ -311,7 +309,6 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
311
309
|
const responsePayload = {
|
|
312
310
|
success: true,
|
|
313
311
|
message: "Login successful",
|
|
314
|
-
sessionId: dbSessionId,
|
|
315
312
|
};
|
|
316
313
|
|
|
317
314
|
if (redirectUrl) {
|
|
@@ -376,10 +373,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
376
373
|
// Password verification (hash-only). We never read/compare plaintext passwords.
|
|
377
374
|
let passwordMatches = false;
|
|
378
375
|
if (user.PasswordEnc) {
|
|
379
|
-
|
|
380
|
-
const stored = Buffer.from(String(user.PasswordEnc), 'utf8');
|
|
381
|
-
const computed = Buffer.from(String(hashedInputPassword), 'utf8');
|
|
382
|
-
passwordMatches = stored.length === computed.length && crypto.timingSafeEqual(stored, computed);
|
|
376
|
+
passwordMatches = await verifyPassword(password, user.UserName, user.PasswordEnc);
|
|
383
377
|
}
|
|
384
378
|
|
|
385
379
|
if (!passwordMatches) {
|
|
@@ -418,7 +412,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
418
412
|
logAuth(`Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
|
|
419
413
|
|
|
420
414
|
const userForSession = {
|
|
421
|
-
|
|
415
|
+
userId: user.UserId || undefined,
|
|
422
416
|
username: user.UserName,
|
|
423
417
|
role: user.Role,
|
|
424
418
|
allowedApps: user.AllowedApps,
|
|
@@ -431,7 +425,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
431
425
|
// 2FA is enabled, prompt for token on a separate page
|
|
432
426
|
const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
|
|
433
427
|
req.session.preAuthUser = {
|
|
434
|
-
|
|
428
|
+
userId: user.UserId || undefined,
|
|
435
429
|
username: user.UserName,
|
|
436
430
|
role: user.Role,
|
|
437
431
|
allowedApps: user.AllowedApps,
|
|
@@ -443,7 +437,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
443
437
|
|
|
444
438
|
// If 2FA is not enabled, proceed with login
|
|
445
439
|
const userForSession = {
|
|
446
|
-
|
|
440
|
+
userId: user.UserId || undefined,
|
|
447
441
|
username: user.UserName,
|
|
448
442
|
role: user.Role,
|
|
449
443
|
allowedApps: user.AllowedApps,
|
|
@@ -493,7 +487,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
493
487
|
}
|
|
494
488
|
|
|
495
489
|
const { token, trustDevice } = req.body;
|
|
496
|
-
const { username,
|
|
490
|
+
const { username, role, userId } = req.session.preAuthUser;
|
|
497
491
|
|
|
498
492
|
// Validate 2FA token
|
|
499
493
|
if (!token || typeof token !== 'string') {
|
|
@@ -541,7 +535,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
541
535
|
}
|
|
542
536
|
|
|
543
537
|
// 2FA successful, complete login with optional device trust
|
|
544
|
-
const userForSession = {
|
|
538
|
+
const userForSession = { userId, username, role, allowedApps };
|
|
545
539
|
// Prefer redirect stored in preAuthUser or in query/body, fallback to configured default
|
|
546
540
|
let redirectFromSession = req.session.preAuthUser && req.session.preAuthUser.redirectUrl ? req.session.preAuthUser.redirectUrl : null;
|
|
547
541
|
if (redirectFromSession && (!(typeof redirectFromSession === 'string') || !redirectFromSession.startsWith('/') || redirectFromSession.startsWith('//'))) {
|
|
@@ -564,7 +558,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
564
558
|
router.post("/api/logout", LogoutLimit, async (req, res) => {
|
|
565
559
|
if (req.session.user) {
|
|
566
560
|
try {
|
|
567
|
-
const {
|
|
561
|
+
const { username } = req.session.user;
|
|
568
562
|
|
|
569
563
|
// Clear profile picture cache
|
|
570
564
|
clearProfilePicCache(req, username);
|
|
@@ -692,7 +686,7 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
|
|
|
692
686
|
});
|
|
693
687
|
|
|
694
688
|
req.session.user = {
|
|
695
|
-
|
|
689
|
+
userId: row.UserId || undefined,
|
|
696
690
|
username: row.UserName,
|
|
697
691
|
role: row.Role,
|
|
698
692
|
sessionId: row.sid,
|
|
@@ -771,6 +765,7 @@ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
|
|
|
771
765
|
githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
|
|
772
766
|
googleLoginEnabled: mbkautheVar.GOOGLE_LOGIN_ENABLED,
|
|
773
767
|
customURL: mbkautheVar.loginRedirectURL || '/dashboard',
|
|
768
|
+
cookieDomain: getCookieDomain() || '',
|
|
774
769
|
userLoggedIn: !!req.session?.user,
|
|
775
770
|
username: req.session?.user?.username || '',
|
|
776
771
|
version: packageJson.version,
|
package/lib/routes/misc.js
CHANGED
|
@@ -6,8 +6,9 @@ import { renderError, renderPage } from "#response.js";
|
|
|
6
6
|
import { authenticate, sessVal, sessRole } from "../middleware/auth.js";
|
|
7
7
|
import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js";
|
|
8
8
|
import { dblogin } from "#pool.js";
|
|
9
|
-
import { clearSessionCookies, decryptSessionId, cachedCookieOptions } from "#cookies.js";
|
|
9
|
+
import { clearSessionCookies, decryptSessionId, cachedCookieOptions, getCookieDomain } from "#cookies.js";
|
|
10
10
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
11
|
+
import { isSafeFetchUrl } from "../utils/urlSafety.js";
|
|
11
12
|
import { fileURLToPath } from "url";
|
|
12
13
|
import path from "path";
|
|
13
14
|
import fs from "fs";
|
|
@@ -65,9 +66,26 @@ const AdminOperationLimit = rateLimit({
|
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
// Static file routes
|
|
69
|
+
const mainJsPath = path.join(__dirname, '..', '..', 'public', 'main.js');
|
|
70
|
+
let mainJsSource = null;
|
|
71
|
+
|
|
72
|
+
const getMainJsSource = () => {
|
|
73
|
+
if (mainJsSource === null) {
|
|
74
|
+
mainJsSource = fs.readFileSync(mainJsPath, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
return mainJsSource;
|
|
77
|
+
};
|
|
78
|
+
|
|
68
79
|
router.get('/main.js', (req, res) => {
|
|
80
|
+
const clientConfig = JSON.stringify({
|
|
81
|
+
cookieDomain: getCookieDomain() || null,
|
|
82
|
+
domain: mbkautheVar.DOMAIN,
|
|
83
|
+
isDeployed: mbkautheVar.IS_DEPLOYED === 'true'
|
|
84
|
+
});
|
|
85
|
+
const injectedSource = `window.mbkautheConfig=${clientConfig};\n${getMainJsSource()}`;
|
|
86
|
+
|
|
69
87
|
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
70
|
-
res.
|
|
88
|
+
res.type('application/javascript').send(injectedSource);
|
|
71
89
|
});
|
|
72
90
|
|
|
73
91
|
router.get('/main.css', (req, res) => {
|
|
@@ -145,6 +163,13 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
145
163
|
return serveDefaultIcon();
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
if (!isSafeFetchUrl(imageUrl)) {
|
|
167
|
+
console.warn(`[mbkauthe] Blocked unsafe profile image URL for user ${username}`);
|
|
168
|
+
res.cookie('profileImageUrl', 'default', { ...cachedCookieOptions, httpOnly: false });
|
|
169
|
+
res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false });
|
|
170
|
+
return serveDefaultIcon();
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
// Fetch and stream the image
|
|
149
174
|
try {
|
|
150
175
|
const imageResponse = await fetch(imageUrl, {
|
|
@@ -187,7 +212,7 @@ if (process.env.env === 'dev') {
|
|
|
187
212
|
success: true,
|
|
188
213
|
message: 'SuperAdmin access granted',
|
|
189
214
|
user: user ? {
|
|
190
|
-
|
|
215
|
+
userId: user.userId,
|
|
191
216
|
username: user.username,
|
|
192
217
|
role: user.role,
|
|
193
218
|
sessionId: user.sessionId
|
|
@@ -202,7 +227,7 @@ if (process.env.env === 'dev') {
|
|
|
202
227
|
|
|
203
228
|
// Test route
|
|
204
229
|
router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
|
|
205
|
-
const { username, fullname, role,
|
|
230
|
+
const { username, fullname, role, userId, sessionId, allowedApps } = req.session.user;
|
|
206
231
|
|
|
207
232
|
const sessionExpiry = req.session.cookie?.expires
|
|
208
233
|
? new Date(req.session.cookie.expires).toISOString()
|
|
@@ -212,7 +237,7 @@ router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
|
|
|
212
237
|
username,
|
|
213
238
|
fullname: fullname || 'N/A',
|
|
214
239
|
role,
|
|
215
|
-
|
|
240
|
+
userId: userId || 'N/A',
|
|
216
241
|
sessionIdShort: sessionId.slice(0, 8),
|
|
217
242
|
profilePicUrl: encodeURIComponent(username),
|
|
218
243
|
displayName: fullname || username,
|
|
@@ -235,7 +260,7 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
|
235
260
|
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
236
261
|
}
|
|
237
262
|
|
|
238
|
-
const {
|
|
263
|
+
const { sessionId } = req.session.user;
|
|
239
264
|
if (!sessionId) {
|
|
240
265
|
req.session.destroy(() => { });
|
|
241
266
|
clearSessionCookies(res);
|
|
@@ -359,7 +384,7 @@ router.post('/api/verifySession', LoginLimit, async (req, res) => {
|
|
|
359
384
|
}
|
|
360
385
|
|
|
361
386
|
const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
|
|
362
|
-
return res.status(200).json({ valid: true, expiry
|
|
387
|
+
return res.status(200).json({ valid: true, expiry });
|
|
363
388
|
} catch (err) {
|
|
364
389
|
console.error(`[mbkauthe] verifySession error:`, err);
|
|
365
390
|
return res.status(200).json({ valid: false, expiry: null });
|
package/lib/routes/oauth.js
CHANGED
|
@@ -75,7 +75,7 @@ const createOAuthStrategy = async (provider, profile, done) => {
|
|
|
75
75
|
|
|
76
76
|
// Return user data for login
|
|
77
77
|
const userData = {
|
|
78
|
-
|
|
78
|
+
userId: user.UserId || undefined,
|
|
79
79
|
username: user.UserName,
|
|
80
80
|
role: user.Role,
|
|
81
81
|
allowedApps: user.AllowedApps,
|
|
@@ -309,7 +309,7 @@ const finishProviderLogin = async (req, res, provider, user, detailValue = '') =
|
|
|
309
309
|
const oauthRedirect = req.session.oauthRedirect;
|
|
310
310
|
if (oauthRedirect) delete req.session.oauthRedirect;
|
|
311
311
|
req.session.preAuthUser = {
|
|
312
|
-
|
|
312
|
+
userId: user.UserId || user.userId || undefined,
|
|
313
313
|
username: user.UserName,
|
|
314
314
|
role: user.Role,
|
|
315
315
|
allowedApps: user.AllowedApps,
|
|
@@ -364,7 +364,7 @@ const createOAuthCallback = (provider, strategy) => {
|
|
|
364
364
|
res,
|
|
365
365
|
provider,
|
|
366
366
|
{
|
|
367
|
-
|
|
367
|
+
UserId: oauthUser.userId,
|
|
368
368
|
UserName: oauthUser.username,
|
|
369
369
|
Role: oauthUser.role,
|
|
370
370
|
AllowedApps: oauthUser.allowedApps,
|
|
@@ -389,7 +389,7 @@ const createOAuthCallback = (provider, strategy) => {
|
|
|
389
389
|
// Helper function to handle OAuth redirect flow
|
|
390
390
|
const handleOAuthRedirect = async (req, res, user, type, method = null) => {
|
|
391
391
|
const userForSession = {
|
|
392
|
-
|
|
392
|
+
userId: user.UserId || user.userId || undefined,
|
|
393
393
|
username: user.UserName,
|
|
394
394
|
role: user.Role,
|
|
395
395
|
allowedApps: user.AllowedApps,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { mbkautheVar } from "#config.js";
|
|
2
|
+
|
|
3
|
+
export function isUserAuthorizedForApp(role, allowedApps) {
|
|
4
|
+
if (role === "SuperAdmin") return true;
|
|
5
|
+
return Array.isArray(allowedApps)
|
|
6
|
+
&& allowedApps.length > 0
|
|
7
|
+
&& allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME);
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that a redirect target is a safe same-origin relative path.
|
|
3
|
+
*/
|
|
4
|
+
export function isSafeRelativeRedirect(value) {
|
|
5
|
+
if (typeof value !== 'string') return false;
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return false;
|
|
8
|
+
if (trimmed.includes('://') || trimmed.includes('\\')) return false;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sanitizeRelativeRedirect(value) {
|
|
13
|
+
return isSafeRelativeRedirect(value) ? value.trim() : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mbkautheVar } from "#config.js";
|
|
2
|
+
|
|
3
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
4
|
+
'localhost',
|
|
5
|
+
'0.0.0.0',
|
|
6
|
+
'metadata.google.internal',
|
|
7
|
+
'metadata',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function isPrivateIpv4(hostname) {
|
|
11
|
+
const parts = hostname.split('.').map((part) => Number(part));
|
|
12
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [a, b] = parts;
|
|
17
|
+
if (a === 10) return true;
|
|
18
|
+
if (a === 127) return true;
|
|
19
|
+
if (a === 0) return true;
|
|
20
|
+
if (a === 169 && b === 254) return true;
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
22
|
+
if (a === 192 && b === 168) return true;
|
|
23
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPrivateIpv6(hostname) {
|
|
28
|
+
const normalized = hostname.toLowerCase();
|
|
29
|
+
if (normalized === '::1' || normalized === '::') return true;
|
|
30
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
|
31
|
+
if (normalized.startsWith('fe80:')) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isBlockedHostname(hostname) {
|
|
36
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
37
|
+
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
|
|
38
|
+
if (normalized.endsWith('.localhost') || normalized.endsWith('.local')) return true;
|
|
39
|
+
if (normalized.includes(':') && isPrivateIpv6(normalized)) return true;
|
|
40
|
+
if (isPrivateIpv4(normalized)) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true when a URL is safe for server-side fetch (profile images, etc.).
|
|
46
|
+
*/
|
|
47
|
+
export function isSafeFetchUrl(urlString) {
|
|
48
|
+
if (!urlString || typeof urlString !== 'string' || urlString === 'default') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(urlString);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const allowHttpInDev = mbkautheVar.IS_DEPLOYED !== 'true';
|
|
60
|
+
const allowedProtocols = allowHttpInDev ? ['https:', 'http:'] : ['https:'];
|
|
61
|
+
if (!allowedProtocols.includes(parsed.protocol)) return false;
|
|
62
|
+
if (parsed.username || parsed.password) return false;
|
|
63
|
+
if (!parsed.hostname) return false;
|
|
64
|
+
if (isBlockedHostname(parsed.hostname)) return false;
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|
package/package.json
CHANGED
package/public/main.js
CHANGED
|
@@ -28,7 +28,19 @@
|
|
|
28
28
|
return [];
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
const domains = new Set();
|
|
32
|
+
const configuredDomain = window.mbkautheConfig?.cookieDomain;
|
|
33
|
+
|
|
34
|
+
if (configuredDomain) {
|
|
35
|
+
domains.add(configuredDomain);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
domains.add(hostname);
|
|
39
|
+
if (hostname.includes('.')) {
|
|
40
|
+
domains.add(`.${hostname}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [...domains];
|
|
32
44
|
};
|
|
33
45
|
|
|
34
46
|
const clearCookie = (name) => {
|
package/test.spec.js
CHANGED
|
@@ -14,7 +14,13 @@ process.env.dbLogs = 'true';
|
|
|
14
14
|
process.env.dbLogsCallsite = 'false';
|
|
15
15
|
|
|
16
16
|
const { default: router } = await import('./lib/main.js');
|
|
17
|
-
const { packageJson } = await import('./lib/config/index.js');
|
|
17
|
+
const { packageJson, mbkautheVar } = await import('./lib/config/index.js');
|
|
18
|
+
const {
|
|
19
|
+
resolveCookieDomain,
|
|
20
|
+
isAllowedOriginHostname,
|
|
21
|
+
getCookieDomain,
|
|
22
|
+
cachedCookieOptions
|
|
23
|
+
} = await import('./lib/config/cookies.js');
|
|
18
24
|
const { dblogin } = await import('./lib/pool.js');
|
|
19
25
|
const {
|
|
20
26
|
attachDevQueryLogger,
|
|
@@ -23,6 +29,11 @@ const {
|
|
|
23
29
|
runWithRequestContext
|
|
24
30
|
} = await import('./lib/utils/dbQueryLogger.js');
|
|
25
31
|
|
|
32
|
+
const { isSafeRelativeRedirect, sanitizeRelativeRedirect } = await import('./lib/utils/redirect.js');
|
|
33
|
+
const { isSafeFetchUrl } = await import('./lib/utils/urlSafety.js');
|
|
34
|
+
const { hashPassword, verifyPassword } = await import('./lib/config/index.js');
|
|
35
|
+
const { hashDeviceToken } = await import('./lib/config/cookies.js');
|
|
36
|
+
|
|
26
37
|
const viewsPath = path.join(__dirname, 'views');
|
|
27
38
|
|
|
28
39
|
const handlebarsHelpers = {
|
|
@@ -252,6 +263,7 @@ describe('mbkauthe Routes', () => {
|
|
|
252
263
|
|
|
253
264
|
expect(response.status).toBe(200);
|
|
254
265
|
expect(response.headers['content-type']).toContain('javascript');
|
|
266
|
+
expect(response.text).toMatch(/^window\.mbkautheConfig=/);
|
|
255
267
|
});
|
|
256
268
|
|
|
257
269
|
test('GET /icon.svg returns SVG content', async () => {
|
|
@@ -680,4 +692,64 @@ describe('mbkauthe Routes', () => {
|
|
|
680
692
|
}
|
|
681
693
|
});
|
|
682
694
|
});
|
|
695
|
+
|
|
696
|
+
describe('Security hardening', () => {
|
|
697
|
+
test('sanitizeRelativeRedirect accepts safe paths and rejects open redirects', () => {
|
|
698
|
+
expect(sanitizeRelativeRedirect('/dashboard')).toBe('/dashboard');
|
|
699
|
+
expect(sanitizeRelativeRedirect('//evil.com')).toBeNull();
|
|
700
|
+
expect(sanitizeRelativeRedirect('https://evil.com')).toBeNull();
|
|
701
|
+
expect(isSafeRelativeRedirect('/app/settings')).toBe(true);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test('isSafeFetchUrl blocks localhost and allows public https URLs', () => {
|
|
705
|
+
expect(isSafeFetchUrl('https://avatars.githubusercontent.com/u/1')).toBe(true);
|
|
706
|
+
expect(isSafeFetchUrl('http://127.0.0.1/secret')).toBe(false);
|
|
707
|
+
expect(isSafeFetchUrl('https://localhost/admin')).toBe(false);
|
|
708
|
+
expect(isSafeFetchUrl('ftp://example.com/icon.png')).toBe(false);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test('verifyPassword validates peppered password hashes', async () => {
|
|
712
|
+
const username = 'test.user';
|
|
713
|
+
const password = 'test-password-123';
|
|
714
|
+
const stored = hashPassword(password, username);
|
|
715
|
+
|
|
716
|
+
expect(stored).toMatch(/^[0-9a-f]{128}$/);
|
|
717
|
+
expect(await verifyPassword(password, username, stored)).toBe(true);
|
|
718
|
+
expect(await verifyPassword('wrong-password', username, stored)).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test('hashDeviceToken returns a keyed hex digest', () => {
|
|
722
|
+
const token = 'a'.repeat(64);
|
|
723
|
+
expect(hashDeviceToken(token)).toMatch(/^[0-9a-f]{64}$/);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('security headers are set on API responses', async () => {
|
|
727
|
+
const response = await request(app).get('/mbkauthe/main.js');
|
|
728
|
+
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
|
729
|
+
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
describe('Cross-subdomain cookie sharing', () => {
|
|
734
|
+
test('resolveCookieDomain returns parent domain only when deployed outside test dev', () => {
|
|
735
|
+
expect(resolveCookieDomain('true', 'mbktech.org', true)).toBeUndefined();
|
|
736
|
+
expect(resolveCookieDomain('false', 'mbktech.org', false)).toBeUndefined();
|
|
737
|
+
expect(resolveCookieDomain('true', 'mbktech.org', false)).toBe('.mbktech.org');
|
|
738
|
+
expect(resolveCookieDomain('true', '.mbktech.org', false)).toBe('.mbktech.org');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('isAllowedOriginHostname accepts root and nested subdomains', () => {
|
|
742
|
+
expect(isAllowedOriginHostname('mbktech.org', 'mbktech.org')).toBe(true);
|
|
743
|
+
expect(isAllowedOriginHostname('auth.mbktech.org', 'mbktech.org')).toBe(true);
|
|
744
|
+
expect(isAllowedOriginHostname('app.auth.mbktech.org', 'mbktech.org')).toBe(true);
|
|
745
|
+
expect(isAllowedOriginHostname('notmbktech.org', 'mbktech.org')).toBe(false);
|
|
746
|
+
expect(isAllowedOriginHostname('mbktech.org.evil.com', 'mbktech.org')).toBe(false);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('cached cookie options omit domain in test dev environment', () => {
|
|
750
|
+
expect(getCookieDomain()).toBeUndefined();
|
|
751
|
+
expect(cachedCookieOptions.domain).toBeUndefined();
|
|
752
|
+
expect(cachedCookieOptions.secure).toBe(false);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
683
755
|
});
|
|
@@ -340,7 +340,15 @@
|
|
|
340
340
|
</style>
|
|
341
341
|
|
|
342
342
|
<script>
|
|
343
|
-
|
|
343
|
+
function sanitizeRelativeRedirect(value) {
|
|
344
|
+
if (typeof value !== 'string') return null;
|
|
345
|
+
const trimmed = value.trim();
|
|
346
|
+
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null;
|
|
347
|
+
if (trimmed.includes('://') || trimmed.includes('\\')) return null;
|
|
348
|
+
return trimmed;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const redirectTarget = sanitizeRelativeRedirect(new URLSearchParams(window.location.search).get('redirect')) || '{{customURL}}';
|
|
344
352
|
|
|
345
353
|
// Ensure login anchors include ?redirect using the redirect query param (not the current page URL)
|
|
346
354
|
(function setLoginHrefFromQuery() {
|
|
@@ -485,16 +493,8 @@
|
|
|
485
493
|
if (res.ok && data.success) {
|
|
486
494
|
// Prioritize explicit ?redirect if present and valid, else fall back to server-provided redirect or template
|
|
487
495
|
const params = new URLSearchParams(window.location.search);
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
try {
|
|
491
|
-
const raw = params.get('redirect');
|
|
492
|
-
if (raw) queryRedirect = decodeURIComponent(raw);
|
|
493
|
-
} catch (e) {
|
|
494
|
-
queryRedirect = null;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const finalRedirect = queryRedirect || data.redirect || redirectTarget;
|
|
496
|
+
const queryRedirect = sanitizeRelativeRedirect(params.get('redirect'));
|
|
497
|
+
const finalRedirect = data.redirect || queryRedirect || redirectTarget;
|
|
498
498
|
if (finalRedirect) window.location.href = finalRedirect;
|
|
499
499
|
} else {
|
|
500
500
|
showMessage(data.message || 'Could not switch account', 'Switch Account');
|
|
@@ -249,6 +249,14 @@
|
|
|
249
249
|
{{> versionInfo}}
|
|
250
250
|
|
|
251
251
|
<script>
|
|
252
|
+
function sanitizeRelativeRedirect(value) {
|
|
253
|
+
if (typeof value !== 'string') return null;
|
|
254
|
+
const trimmed = value.trim();
|
|
255
|
+
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null;
|
|
256
|
+
if (trimmed.includes('://') || trimmed.includes('\\')) return null;
|
|
257
|
+
return trimmed;
|
|
258
|
+
}
|
|
259
|
+
|
|
252
260
|
// Toggle password visibility
|
|
253
261
|
const togglePassword = document.getElementById('togglePassword');
|
|
254
262
|
const passwordInput = document.getElementById('loginPassword');
|
|
@@ -301,7 +309,7 @@
|
|
|
301
309
|
|
|
302
310
|
// Pass redirect query param through to server so it can be used by 2FA flow
|
|
303
311
|
const urlParams = new URLSearchParams(window.location.search);
|
|
304
|
-
const pageRedirect = urlParams.get('redirect');
|
|
312
|
+
const pageRedirect = sanitizeRelativeRedirect(urlParams.get('redirect'));
|
|
305
313
|
fetch('/mbkauthe/api/login', {
|
|
306
314
|
method: 'POST',
|
|
307
315
|
credentials: 'include',
|
|
@@ -330,9 +338,8 @@
|
|
|
330
338
|
deleteCookie('rememberedUsername');
|
|
331
339
|
}
|
|
332
340
|
|
|
333
|
-
// Redirect to the
|
|
334
|
-
|
|
335
|
-
window.location.href = redirectUrl ? decodeURIComponent(redirectUrl) : '{{customURL}}';
|
|
341
|
+
// Redirect to the validated target from the server or safe query param
|
|
342
|
+
window.location.href = data.redirectUrl || pageRedirect || '{{customURL}}';
|
|
336
343
|
}
|
|
337
344
|
} else {
|
|
338
345
|
// Handle errors
|
|
@@ -349,7 +356,9 @@
|
|
|
349
356
|
});
|
|
350
357
|
});
|
|
351
358
|
|
|
352
|
-
// Cookie helper functions for cross-
|
|
359
|
+
// Cookie helper functions for cross-subdomain sharing
|
|
360
|
+
const cookieDomain = '{{cookieDomain}}';
|
|
361
|
+
|
|
353
362
|
function setCookie(name, value, days) {
|
|
354
363
|
let expires = "";
|
|
355
364
|
if (days) {
|
|
@@ -357,11 +366,11 @@
|
|
|
357
366
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
|
358
367
|
expires = "; expires=" + date.toUTCString();
|
|
359
368
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
document.cookie =
|
|
369
|
+
let cookieValue = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
|
|
370
|
+
if (cookieDomain) {
|
|
371
|
+
cookieValue += "; domain=" + cookieDomain;
|
|
372
|
+
}
|
|
373
|
+
document.cookie = cookieValue;
|
|
365
374
|
}
|
|
366
375
|
|
|
367
376
|
function getCookie(name) {
|
|
@@ -376,19 +385,26 @@
|
|
|
376
385
|
}
|
|
377
386
|
|
|
378
387
|
function deleteCookie(name) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
388
|
+
let cookieValue = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";
|
|
389
|
+
if (cookieDomain) {
|
|
390
|
+
cookieValue += "; domain=" + cookieDomain;
|
|
391
|
+
}
|
|
392
|
+
document.cookie = cookieValue;
|
|
383
393
|
}
|
|
384
394
|
|
|
385
395
|
// Check for URL parameters
|
|
386
396
|
document.addEventListener('DOMContentLoaded', function () {
|
|
387
397
|
const urlParams = new URLSearchParams(window.location.search);
|
|
388
|
-
const usernameFromUrl = urlParams.get('username');
|
|
389
|
-
const passwordFromUrl = urlParams.get('password');
|
|
390
398
|
const usernameInput = document.getElementById('loginUsername');
|
|
391
399
|
|
|
400
|
+
if (urlParams.has('password')) {
|
|
401
|
+
urlParams.delete('password');
|
|
402
|
+
const sanitizedQuery = urlParams.toString();
|
|
403
|
+
const nextUrl = `${window.location.pathname}${sanitizedQuery ? `?${sanitizedQuery}` : ''}${window.location.hash}`;
|
|
404
|
+
window.history.replaceState({}, document.title, nextUrl);
|
|
405
|
+
showMessage('Passwords must not be passed in the URL. Enter your password in the form instead.', 'Security Notice');
|
|
406
|
+
}
|
|
407
|
+
|
|
392
408
|
// Check for remembered username in cookies
|
|
393
409
|
const rememberedUsername = getCookie('rememberedUsername');
|
|
394
410
|
if (rememberedUsername) {
|
|
@@ -396,14 +412,6 @@
|
|
|
396
412
|
document.getElementById('rememberMe').checked = true;
|
|
397
413
|
}
|
|
398
414
|
|
|
399
|
-
if (usernameFromUrl) {
|
|
400
|
-
usernameInput.value = usernameFromUrl;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (passwordFromUrl) {
|
|
404
|
-
document.getElementById('loginPassword').value = passwordFromUrl;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
415
|
// Automatically focus the first empty field
|
|
408
416
|
if (!usernameInput.value) {
|
|
409
417
|
usernameInput.focus();
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
<div>
|
|
195
195
|
<div class="session-status">✅ Authentication successful</div>
|
|
196
196
|
<h3 class="session-title">{{username}} <small class="session-role">· {{role}}</small></h3>
|
|
197
|
-
<p class="session-sub">
|
|
197
|
+
<p class="session-sub">UserId: {{userId}} · Session: {{sessionIdShort}}…</p>
|
|
198
198
|
</div>
|
|
199
199
|
|
|
200
200
|
<div class="session-details" aria-live="polite">
|