UNPKG

@syngrisi/syngrisi

Version:
478 lines (425 loc) 18.1 kB
/* 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(); }; }