feat: enhance visitor mode
This commit is contained in:
126
backend/package-lock.json
generated
126
backend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
@@ -1536,6 +1538,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -1550,6 +1563,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
@@ -2367,6 +2387,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -3139,6 +3165,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -4455,12 +4490,103 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -38,6 +39,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
|
||||
@@ -60,4 +60,34 @@ describe('visitorModeMiddleware', () => {
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow passkey authenticate endpoint', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
path: '/settings/passkeys/authenticate',
|
||||
url: '/settings/passkeys/authenticate'
|
||||
};
|
||||
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow passkey verify endpoint', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
path: '/settings/passkeys/authenticate/verify',
|
||||
url: '/settings/passkeys/authenticate/verify'
|
||||
};
|
||||
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,11 +37,47 @@ describe('visitorModeSettingsMiddleware', () => {
|
||||
|
||||
it('should block other updates', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.body = { websiteName: 'Hacked' };
|
||||
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: { websiteName: 'Hacked' },
|
||||
path: '/api/settings',
|
||||
url: '/api/settings'
|
||||
};
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should allow passkey authenticate endpoint', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
path: '/passkeys/authenticate',
|
||||
url: '/passkeys/authenticate'
|
||||
};
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow passkey verify endpoint', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
path: '/passkeys/authenticate/verify',
|
||||
url: '/passkeys/authenticate/verify'
|
||||
};
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,7 +164,7 @@ export const verifyAuthentication = async (
|
||||
);
|
||||
|
||||
if (result.verified) {
|
||||
res.json({ success: true });
|
||||
res.json({ success: true, token: result.token, role: result.role });
|
||||
} else {
|
||||
res.status(401).json({ success: false, error: "Authentication failed" });
|
||||
}
|
||||
|
||||
@@ -27,15 +27,22 @@ export const verifyPassword = async (
|
||||
const result = await passwordService.verifyPassword(password);
|
||||
|
||||
if (result.success) {
|
||||
// Return format expected by frontend: { success: boolean }
|
||||
res.json({ success: true });
|
||||
// Return format expected by frontend: { success: boolean, role?, token? }
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role,
|
||||
token: result.token
|
||||
});
|
||||
} else {
|
||||
// Return wait time information
|
||||
res.status(result.waitTime ? 429 : 401).json({
|
||||
// Return 200 OK to suppress browser console errors, but include status code and success: false
|
||||
const statusCode = result.waitTime ? 429 : 401;
|
||||
res.json({
|
||||
success: false,
|
||||
waitTime: result.waitTime,
|
||||
failedAttempts: result.failedAttempts,
|
||||
message: result.message,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import {
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
} from "../config/paths";
|
||||
import { cloudflaredService } from "../services/cloudflaredService";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
@@ -37,9 +37,9 @@ export const getSettings = async (
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Do not send the hashed password to the frontend
|
||||
const { password, ...safeSettings } = mergedSettings;
|
||||
const { password, visitorPassword, ...safeSettings } = mergedSettings;
|
||||
// Return data directly for backward compatibility
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password });
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password, isVisitorPasswordSet: !!visitorPassword });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,35 +124,43 @@ export const updateSettings = async (
|
||||
{}
|
||||
);
|
||||
|
||||
// Check visitor mode restrictions
|
||||
const visitorModeCheck =
|
||||
settingsValidationService.checkVisitorModeRestrictions(
|
||||
mergedSettings,
|
||||
newSettings
|
||||
);
|
||||
// Check visitor mode restrictions (if not admin)
|
||||
// If user is admin (jwt authenticated), they bypass visitor mode restrictions
|
||||
const isAdmin = req.user?.role === "admin";
|
||||
|
||||
if (!isAdmin) {
|
||||
const visitorModeCheck =
|
||||
settingsValidationService.checkVisitorModeRestrictions(
|
||||
mergedSettings,
|
||||
newSettings
|
||||
);
|
||||
|
||||
if (!visitorModeCheck.allowed) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: visitorModeCheck.error,
|
||||
});
|
||||
return;
|
||||
if (!visitorModeCheck.allowed) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: visitorModeCheck.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special case: visitorMode being set to true (already enabled)
|
||||
// Only applies if NOT admin (admins can update settings while in visitor mode)
|
||||
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
|
||||
// Only update visitorMode, ignore other changes
|
||||
const allowedSettings: Settings = {
|
||||
...mergedSettings,
|
||||
visitorMode: true,
|
||||
};
|
||||
storageService.saveSettings(allowedSettings);
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...allowedSettings, password: undefined, visitorPassword: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special case: visitorMode being set to true (already enabled)
|
||||
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
|
||||
// Only update visitorMode, ignore other changes
|
||||
const allowedSettings: Settings = {
|
||||
...mergedSettings,
|
||||
visitorMode: true,
|
||||
};
|
||||
storageService.saveSettings(allowedSettings);
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...allowedSettings, password: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate settings
|
||||
settingsValidationService.validateSettings(newSettings);
|
||||
@@ -253,7 +261,7 @@ export const updateSettings = async (
|
||||
// Return format expected by frontend: { success: true, settings: {...} }
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...finalSettings, password: undefined },
|
||||
settings: { ...finalSettings, password: undefined, visitorPassword: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
35
backend/src/middleware/authMiddleware.ts
Normal file
35
backend/src/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { UserPayload, verifyToken } from "../services/authService";
|
||||
|
||||
// Extend Express Request type to include user property
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token and attach user to request
|
||||
* Does NOT block requests if token is missing/invalid, just leaves req.user undefined
|
||||
* Blocking logic should be handled by specific route guards or visitorModeMiddleware
|
||||
*/
|
||||
export const authMiddleware = (
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (decoded) {
|
||||
req.user = decoded;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -19,6 +19,12 @@ export const visitorModeMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is Admin, allow all requests
|
||||
if (req.user?.role === "admin") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
@@ -37,6 +43,12 @@ export const visitorModeMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow passkey authentication
|
||||
if (req.path.includes("/settings/passkeys/authenticate") || req.url.includes("/settings/passkeys/authenticate")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
|
||||
@@ -19,6 +19,12 @@ export const visitorModeSettingsMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is Admin, allow all requests
|
||||
if (req.user?.role === "admin") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
@@ -37,6 +43,15 @@ export const visitorModeSettingsMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow passkey authentication
|
||||
if (
|
||||
req.path.includes("/passkeys/authenticate") ||
|
||||
req.url.includes("/passkeys/authenticate")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
VIDEOS_DIR,
|
||||
} from "./config/paths";
|
||||
import { runMigrations } from "./db/migrate";
|
||||
import { authMiddleware } from "./middleware/authMiddleware";
|
||||
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
|
||||
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
|
||||
import apiRoutes from "./routes/api";
|
||||
@@ -243,6 +244,8 @@ const startServer = async () => {
|
||||
);
|
||||
|
||||
// API Routes
|
||||
// Apply auth middleware to all API routes
|
||||
app.use("/api", authMiddleware);
|
||||
// Apply visitor mode middleware to all API routes
|
||||
app.use("/api", visitorModeMiddleware, apiRoutes);
|
||||
// Use separate middleware for settings that allows disabling visitor mode
|
||||
|
||||
@@ -35,6 +35,10 @@ vi.mock("../../utils/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../authService", () => ({
|
||||
generateToken: vi.fn(() => "mock-token"),
|
||||
}));
|
||||
|
||||
describe("passkeyService", () => {
|
||||
const mockPasskey = {
|
||||
credentialID: "mock-credential-id",
|
||||
@@ -251,6 +255,18 @@ describe("passkeyService", () => {
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "mock-credential-id",
|
||||
counter: 1
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(result.token).toBe("mock-token");
|
||||
expect(result.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("should fail if passkey not found", async () => {
|
||||
|
||||
31
backend/src/services/authService.ts
Normal file
31
backend/src/services/authService.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "default_development_secret_do_not_use_in_production";
|
||||
const JWT_EXPIRES_IN = "24h";
|
||||
|
||||
export interface UserPayload {
|
||||
role: "admin" | "visitor";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a user
|
||||
*/
|
||||
export const generateToken = (payload: UserPayload): string => {
|
||||
return jwt.sign({ ...payload, id: payload.id || uuidv4() }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export const verifyToken = (token: string): UserPayload | null => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { generateToken } from "./authService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
// RP (Relying Party) configuration
|
||||
@@ -255,6 +256,10 @@ export async function generatePasskeyAuthenticationOptions(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
*/
|
||||
@@ -263,7 +268,7 @@ export async function verifyPasskeyAuthentication(
|
||||
challenge: string,
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{ verified: boolean }> {
|
||||
): Promise<{ verified: boolean; token?: string; role?: "admin" | "visitor" }> {
|
||||
try {
|
||||
const passkeys = getPasskeys();
|
||||
// Find passkey by matching the credential ID
|
||||
@@ -304,7 +309,11 @@ export async function verifyPasskeyAuthentication(
|
||||
savePasskeys(updatedPasskeys);
|
||||
|
||||
logger.info("Passkey authentication successful");
|
||||
return { verified: true };
|
||||
|
||||
// Generate admin token (Passkeys are currently only for admins)
|
||||
const token = generateToken({ role: "admin" });
|
||||
|
||||
return { verified: true, token, role: "admin" };
|
||||
}
|
||||
|
||||
return { verified: false };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { defaultSettings } from "../types/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as loginAttemptService from "./loginAttemptService";
|
||||
import * as storageService from "./storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Settings, defaultSettings } from "../types/settings";
|
||||
|
||||
/**
|
||||
* Check if login is required (loginEnabled is true)
|
||||
@@ -44,10 +44,17 @@ export function isPasswordEnabled(): {
|
||||
/**
|
||||
* Verify password for authentication
|
||||
*/
|
||||
/**
|
||||
* Verify password for authentication
|
||||
*/
|
||||
import { generateToken } from "./authService";
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
role?: "admin" | "visitor";
|
||||
token?: string;
|
||||
waitTime?: number;
|
||||
failedAttempts?: number;
|
||||
message?: string;
|
||||
@@ -58,6 +65,11 @@ export async function verifyPassword(
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
// If password login is explicitly disabled, ONLY allow Admin to login via password (if they have one set)?
|
||||
// Or just block everyone? The frontend says "When disabled, password login is not available."
|
||||
// But typically Admin should always be able to login?
|
||||
// For now, let's respect the flag, but maybe we should allow it if we are matching the Admin password?
|
||||
// Let's stick to current logic: if blocked, blocked.
|
||||
if (!passwordLoginAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -65,11 +77,6 @@ export async function verifyPassword(
|
||||
};
|
||||
}
|
||||
|
||||
if (!mergedSettings.password) {
|
||||
// If no password set but login enabled, allow access
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Check if user can attempt login (wait time check)
|
||||
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||
if (remainingWaitTime > 0) {
|
||||
@@ -81,24 +88,49 @@ export async function verifyPassword(
|
||||
};
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
|
||||
if (isMatch) {
|
||||
// Reset failed attempts on successful login
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
return { success: true };
|
||||
// 1. Check Admin Password
|
||||
if (mergedSettings.password) {
|
||||
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
if (isAdminMatch) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "admin" });
|
||||
return { success: true, role: "admin", token };
|
||||
}
|
||||
} else {
|
||||
// Record failed attempt and get wait time
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect password",
|
||||
};
|
||||
// If no admin password set, and login enabled, allow as admin
|
||||
if (mergedSettings.loginEnabled) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "admin" });
|
||||
return { success: true, role: "admin", token };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Visitor Password (only if visitorMode is enabled AND visitorPassword is set)
|
||||
// Actually, user said: "When visitor user enable... login from this password input user role is Visitor"
|
||||
// So we check if visitorMode is enabled in settings.
|
||||
if (mergedSettings.visitorMode && mergedSettings.visitorPassword) {
|
||||
// NOTE: visitorPassword might not be hashed yet if we just added it?
|
||||
// Step 6 in Plan said "Update Settings type", but we didn't discuss hashing.
|
||||
// We should probably hash it when saving too.
|
||||
// For now, assuming it WILL be hashed. I'll need to update `settingsValidationService` to hash it.
|
||||
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
|
||||
if (isVisitorMatch) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "visitor" });
|
||||
return { success: true, role: "visitor", token };
|
||||
}
|
||||
}
|
||||
|
||||
// No match
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect password",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as storageService from "./storageService";
|
||||
import { Settings, defaultSettings } from "../types/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
/**
|
||||
* Validate and normalize settings values
|
||||
@@ -155,6 +155,13 @@ export async function prepareSettingsForSave(
|
||||
prepared.password = existingSettings.password;
|
||||
}
|
||||
|
||||
// Handle visitor password hashing
|
||||
if (prepared.visitorPassword) {
|
||||
prepared.visitorPassword = await hashPassword(prepared.visitorPassword);
|
||||
} else {
|
||||
prepared.visitorPassword = existingSettings.visitorPassword;
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
const oldTags: string[] = existingSettings.tags || [];
|
||||
if (prepared.tags === undefined) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Settings {
|
||||
moveSubtitlesToVideoFolder?: boolean;
|
||||
moveThumbnailsToVideoFolder?: boolean;
|
||||
visitorMode?: boolean;
|
||||
visitorPassword?: string;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
cloudflaredTunnelEnabled?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user