@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
478 lines (425 loc) • 18.1 kB
text/typescript
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Ensure that a user is logged in before proceeding to next route middleware.
*
* This middleware ensures that a user is logged in. If a request is received
* that is unauthenticated, the request will be redirected to a login page (by
* default to `/login`).
*
* Additionally, `returnTo` will be be set in the session to the URL of the
* current request. After authentication, this value can be used to redirect
* the user to the page that was originally requested.
*
* Options:
* - `redirectTo` URL to redirect to for login, defaults to _/login_
* - `setReturnTo` set redirectTo in session, defaults to _true_
*
* Examples:
*
* app.get('/profile',
* ensureLoggedIn(),
* function(req, res) { ... });
*
* app.get('/profile',
* ensureLoggedIn('/signin'),
* function(req, res) { ... });
*
* app.get('/profile',
* ensureLoggedIn({ redirectTo: '/session/new', setReturnTo: false }),
* function(req, res) { ... });
*
* @param {Object} options
* @return {Function}
* @api public
*/
import { Request, Response, NextFunction } from 'express';
import { User } from '@models';
import log from "../../lib/logger";
import { ExtRequest } from '../../../types/ExtRequest';
import { appSettings } from "@settings";
import { env } from "@/server/envConfig";
import { hashSync } from '@utils/hash';
import * as shareService from '@services/share.service';
import { executeAuthHook } from '../../plugins';
import { ensureGuestUserExists } from '@lib/startup/createBasicUsers';
import { adminDataJobService } from '@services/admin-data-job.service';
const transientGuestUser = {
username: 'Guest',
role: 'user',
firstName: 'Syngrisi',
lastName: 'Guest',
};
export const normalizeIncomingApiKey = (rawKey: unknown): string | undefined => {
if (Array.isArray(rawKey)) {
rawKey = rawKey[0];
}
if (rawKey === undefined || rawKey === null) return undefined;
const apiKey = String(rawKey).trim();
if (!apiKey) return undefined;
const hashedPattern = /^[a-f0-9]{128}$/i;
if (hashedPattern.test(apiKey)) return apiKey;
return hashSync(apiKey);
};
const handleBasicAuth = async (req: ExtRequest, retryCount = 0): Promise<any> => {
const logOpts = {
scope: 'handleBasicAuth',
msgType: 'AUTH_API',
};
const MAX_RETRIES = 10;
const RETRY_DELAY_MS = 500;
const AppSettings = appSettings;
const authEnabled = await AppSettings.isAuthEnabled();
log.debug(`handleBasicAuth: checking auth`, {
...logOpts,
authEnabled,
isAuthenticated: req.isAuthenticated(),
hasUser: !!req.user,
username: req.user?.username,
});
if (req.isAuthenticated()) {
log.debug(`handleBasicAuth: user already authenticated, returning success`, logOpts);
return { type: 'success', status: 200 };
}
if (!authEnabled) {
const guest = await User.findOne({ username: 'Guest' });
// Retry logic for Guest user lookup during startup (MongoDB indexing race condition)
if (!guest) {
if (await adminDataJobService.hasActiveDatabaseRestoreJob()) {
log.warn('Guest user is temporarily unavailable during active database restore, using transient guest user', logOpts);
return {
type: 'success',
status: 200,
value: '',
user: transientGuestUser,
};
}
await ensureGuestUserExists().catch(() => undefined);
if (retryCount < MAX_RETRIES) {
log.warn(`Guest user not found in handleBasicAuth (attempt ${retryCount + 1}/${MAX_RETRIES}), retrying in ${RETRY_DELAY_MS}ms...`, logOpts);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
return handleBasicAuth(req, retryCount + 1);
}
log.error(`cannot find Guest user after ${MAX_RETRIES} retries`, logOpts);
return {
type: 'redirect',
status: 301,
value: `/auth?=Error: cannot find Guest user after ${MAX_RETRIES} retries`,
user: null,
};
}
// When auth is disabled, bypass session-based login to avoid hanging on API requests
// that don't have an established session. Just set the user directly.
log.debug(`Auth disabled - setting Guest user directly (bypassing session login)`, logOpts);
return {
type: 'success',
status: 200,
value: '',
user: guest,
};
}
const result: any = {
type: 'error',
status: 400,
value: '',
user: null,
};
if (authEnabled
&& ((await AppSettings.isFirstRun()))
&& !env.SYNGRISI_DISABLE_FIRST_RUN
) {
log.info('first run, set admin password', logOpts);
result.type = 'redirect';
result.status = 301;
result.value = '/auth/change?first_run=true';
return result;
}
if (authEnabled) {
log.info(`user is not authenticated, will redirected - ${req.originalUrl}`, logOpts);
result.type = 'redirect';
result.status = 301;
if (req?.originalUrl !== '/') {
result.value = `/auth?origin=${encodeURIComponent(req.originalUrl)}`;
return result;
}
result.value = '/auth';
return result;
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function ensureLoggedIn(options?: any): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
const result = await handleBasicAuth(req);
req.user = result.user || req.user;
if (result.type === 'success') {
return next();
}
res.status(result.status).redirect(result.value);
// return next('redirect'); // Do not call next with error for redirect
};
}
const handleAPIAuth = async (rawApiKey: unknown, retryCount = 0): Promise<any> => {
const logOpts = {
scope: 'handleAPIAuth',
msgType: 'AUTH_API',
};
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 500;
const result: any = {
status: 400,
type: 'error',
value: '',
user: null,
};
const AppSettings = appSettings;
if (!(await AppSettings.isAuthEnabled())) {
const guest = await User.findOne({ username: 'Guest' });
if (!guest) {
if (await adminDataJobService.hasActiveDatabaseRestoreJob()) {
log.warn('Guest user is temporarily unavailable during active database restore, using transient guest user', logOpts);
result.type = 'success';
result.user = transientGuestUser;
result.status = 200;
return result;
}
await ensureGuestUserExists().catch(() => undefined);
// Retry logic for Guest user lookup during startup (MongoDB indexing race condition)
if (retryCount < MAX_RETRIES) {
log.warn(`Guest user not found (attempt ${retryCount + 1}/${MAX_RETRIES}), retrying in ${RETRY_DELAY_MS}ms...`, logOpts);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
return handleAPIAuth(rawApiKey, retryCount + 1);
}
log.error(`cannot find Guest user after ${MAX_RETRIES} retries`, logOpts);
result.type = 'error';
result.value = 'cannot find Guest user';
return result;
}
log.debug('authentication disabled', logOpts, { user: 'Guest' });
result.type = 'success';
result.user = guest;
result.status = 200;
return result;
}
const hashedApiKey = normalizeIncomingApiKey(rawApiKey);
if (!hashedApiKey) {
log.debug('API key missing', logOpts);
result.type = 'error';
result.status = 401;
result.value = 'API key missing';
return result;
}
const user = await User.findOne({ apiKey: hashedApiKey });
if (!user) {
// Retry logic for API key lookup during startup (MongoDB indexing race condition)
if (retryCount < MAX_RETRIES) {
log.warn(`API key not found (attempt ${retryCount + 1}/${MAX_RETRIES}), retrying in ${RETRY_DELAY_MS}ms...`, logOpts);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
return handleAPIAuth(rawApiKey, retryCount + 1);
}
log.error(`wrong API key provided after ${MAX_RETRIES} retries`, logOpts);
result.type = 'error';
result.status = 401;
result.value = 'wrong API key';
return result;
}
log.debug('authenticated', { ...logOpts, ...{ user: user?.username } });
result.type = 'success';
result.status = 200;
result.user = user;
return result;
};
export function ensureApiKey(): (req: Request, res: Response, next: NextFunction) => Promise<void> {
const logOpts = {
scope: 'ensureApiKey',
msgType: 'AUTH_API',
};
return async (req: Request, res: Response, next: NextFunction) => {
// Try plugin authentication first (e.g., JWT tokens)
try {
const pluginAuthResult = await executeAuthHook(req, res);
if (pluginAuthResult) {
if (pluginAuthResult.authenticated && pluginAuthResult.user) {
req.user = pluginAuthResult.user;
return next();
}
if (pluginAuthResult.authenticated === false) {
log.info(`Plugin auth failed: ${pluginAuthResult.error}`, logOpts);
return res.status(401).json({ error: pluginAuthResult.error || 'Plugin authentication failed' });
}
}
} catch (error) {
log.error(`Error in plugin auth: ${error}`, logOpts);
}
const rawApiKey = req.headers.apikey ?? req.query.apikey;
const result = await handleAPIAuth(rawApiKey);
req.user = req.user || result.user;
if ('apikey' in req.query) {
delete (req.query as Record<string, unknown>).apikey;
}
if (result.type !== 'success') {
log.info(`${result.value} - ${req.originalUrl}`, logOpts);
return res.status(result.status).json({ error: result.value });
}
if ('apikey' in req.headers) {
delete (req.headers as Record<string, unknown>).apikey;
}
return next();
};
}
export function ensureLoggedInOrApiKey(): (req: Request, res: Response, next: NextFunction) => Promise<void> {
const logOpts = {
scope: 'ensureLoggedInOrApiKey',
msgType: 'AUTH_API',
};
return async (req: Request, res: Response, next: NextFunction) => {
// Try plugin authentication first (e.g., JWT tokens from external IdP)
try {
const pluginAuthResult = await executeAuthHook(req, res);
if (pluginAuthResult) {
if (pluginAuthResult.authenticated && pluginAuthResult.user) {
log.debug(`Plugin auth succeeded for user: ${pluginAuthResult.user.username}`, logOpts);
req.user = pluginAuthResult.user;
return next();
}
if (pluginAuthResult.authenticated === false) {
log.info(`Plugin auth failed: ${pluginAuthResult.error}`, logOpts);
return res.status(401).json({ error: pluginAuthResult.error || 'Plugin authentication failed' });
}
}
// pluginAuthResult === null means skip plugin auth, try other methods
} catch (error) {
log.error(`Error in plugin auth: ${error}`, logOpts);
// Continue to regular auth on plugin error
}
const basicAuthResult = await handleBasicAuth(req);
req.user = basicAuthResult.user || req.user;
if (basicAuthResult.type === 'success') {
return next();
}
const rawApiKey = req.headers.apikey ?? req.query.apikey;
const apiKeyResult = await handleAPIAuth(rawApiKey);
req.user = req.user || apiKeyResult.user;
if ('apikey' in req.query) {
delete (req.query as Record<string, unknown>).apikey;
}
if (apiKeyResult.type !== 'success') {
log.info(`Unauthorized - ${req.originalUrl}`);
res.status(401).json({ error: `Unauthorized - ${req.originalUrl}` });
return next(new Error(`Unauthorized - ${req.originalUrl}`));
}
if ('apikey' in req.headers) {
delete (req.headers as Record<string, unknown>).apikey;
}
return next();
};
}
/**
* Middleware that allows access if:
* 1. User is logged in, OR
* 2. A valid share token is present in query params
*
* Used for pages that can be accessed anonymously via share links.
*/
import * as shareService from '@services/share.service';
// ... (existing imports)
// ...
export function ensureLoggedInOrShareToken(): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
// Check if share token is present in query params
const shareToken = req.query.share as string | undefined;
if (shareToken) {
// Validate share token
const tokenDoc = await shareService.findShareToken(shareToken);
if (!tokenDoc) {
log.warn('Invalid share token', { scope: 'ensureLoggedInOrShareToken' });
// If invalid, fall through to normal auth or redirect
// We can continue to handleBasicAuth which will redirect to login
} else {
// Share token present AND valid - allow access without login
log.debug('Valid share token present, allowing access without login', { scope: 'ensureLoggedInOrShareToken' });
// Do NOT login as Guest to prevent session creation with write access
(req as any).isShareMode = true;
(req as any).shareToken = tokenDoc;
return next();
}
}
// No share token - require normal authentication
const result = await handleBasicAuth(req);
req.user = result.user || req.user;
if (result.type === 'success') {
return next();
}
res.status(result.status).redirect(result.value);
};
}
/**
* Middleware that allows access if:
* 1. User is logged in, OR
* 2. Valid API key, OR
* 3. Valid share token
*
* Used for API endpoints that can be accessed via share links (read-only).
*/
export function ensureLoggedInOrApiKeyOrShareToken(): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
const logOpts = {
scope: 'ensureLoggedInOrApiKeyOrShareToken',
msgType: 'AUTH_API',
};
// Check if share token is present in query params or headers
const shareToken = (req.query.share || req.headers['x-share-token']) as string | undefined;
if (shareToken) {
// Validate share token
const tokenDoc = await shareService.findShareToken(shareToken);
if (tokenDoc) {
// Share token present and valid - allow read access without login
log.debug('Valid share token present in API request, allowing read access', logOpts);
// Login as Guest user for share mode (with retry for MongoDB indexing race condition)
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 500;
let guest = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
guest = await User.findOne({ username: 'Guest' });
if (guest) break;
if (attempt < MAX_RETRIES - 1) {
log.warn(`Guest user not found for share API access (attempt ${attempt + 1}/${MAX_RETRIES}), retrying...`, logOpts);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
}
}
if (guest) {
req.user = guest;
// Mark request as share mode to skip creator filtering
(req as any).isShareMode = true;
(req as any).shareToken = tokenDoc;
return next();
} else {
log.error(`Guest user not found for share API access after ${MAX_RETRIES} retries`, logOpts);
}
} else {
log.warn('Invalid share token in API request', logOpts);
}
}
// Try basic auth first
const basicAuthResult = await handleBasicAuth(req);
req.user = basicAuthResult.user || req.user;
if (basicAuthResult.type === 'success') {
return next();
}
// Try API key
const rawApiKey = req.headers.apikey ?? req.query.apikey;
const apiKeyResult = await handleAPIAuth(rawApiKey);
req.user = req.user || apiKeyResult.user;
if ('apikey' in req.query) {
delete (req.query as Record<string, unknown>).apikey;
}
if (apiKeyResult.type !== 'success') {
log.info(`Unauthorized - ${req.originalUrl}`, logOpts);
return res.status(401).json({ error: `Unauthorized - ${req.originalUrl}` });
}
if ('apikey' in req.headers) {
delete (req.headers as Record<string, unknown>).apikey;
}
return next();
};
}