diff --git a/client/00-startup.js b/client/00-startup.js index a1afb9664..d5a553461 100644 --- a/client/00-startup.js +++ b/client/00-startup.js @@ -60,6 +60,22 @@ import { Utils } from '/client/lib/utils'; // Token already lives in localStorage; cookie adds same-origin send-on-request semantics Meteor.startup(() => { const COOKIE_NAME = 'meteor_login_token'; + const USER_ID_COOKIE = 'meteor_user_id'; + const TOKEN_EXPIRES_COOKIE = 'meteor_login_token_expires'; + + const getCookie = (name) => { + try { + const parts = document.cookie ? document.cookie.split(';') : []; + for (const part of parts) { + const [k, ...rest] = part.trim().split('='); + if (decodeURIComponent(k) === name) { + return decodeURIComponent(rest.join('=')); + } + } + } catch (_) {} + return ''; + }; + const cookieAttrs = () => { const attrs = ['Path=/', 'SameSite=Lax']; try { @@ -87,6 +103,37 @@ Meteor.startup(() => { } }; + const bootstrapTokenFromCookie = () => { + try { + const currentToken = Accounts && typeof Accounts._storedLoginToken === 'function' + ? Accounts._storedLoginToken() + : null; + if (currentToken) { + return; + } + + const token = getCookie(COOKIE_NAME); + const userId = getCookie(USER_ID_COOKIE); + const tokenExpiresRaw = getCookie(TOKEN_EXPIRES_COOKIE); + const tokenExpires = tokenExpiresRaw ? new Date(tokenExpiresRaw) : null; + + if (!token || !userId || !tokenExpires || Number.isNaN(tokenExpires.getTime())) { + return; + } + + if (Accounts && typeof Accounts._storeLoginToken === 'function') { + Accounts._storeLoginToken(userId, token, tokenExpires); + if (Meteor.status && !Meteor.status().connected && typeof Meteor.reconnect === 'function') { + Meteor.reconnect(); + } + } + } catch (_) { + // ignore + } + }; + + bootstrapTokenFromCookie(); + // Initial sync on startup syncCookie(); diff --git a/server/header-login.js b/server/header-login.js index 8ab8a2b91..7b1baeee3 100644 --- a/server/header-login.js +++ b/server/header-login.js @@ -1,8 +1,78 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import { WebApp } from 'meteor/webapp'; +import { + findOrCreateHeaderLoginUser, + isTrustedHeaderLoginSource, + shouldProcessHeaderLoginMiddlewareRequest, +} from '/server/lib/headerLoginAuth'; + +async function issueLoginTokenCookies(userId, req, res) { + if (!userId) { + return; + } + + const stampedToken = Accounts._generateStampedLoginToken(); + const hashedToken = Accounts._hashStampedToken(stampedToken); + const tokenExpires = Accounts._tokenExpiration(stampedToken.when); + + await Meteor.users.updateAsync( + { _id: userId }, + { $push: { 'services.resume.loginTokens': hashedToken } }, + ); + + const cookieBase = ['Path=/', 'SameSite=Lax']; + if (req?.headers?.['x-forwarded-proto'] === 'https' || req?.socket?.encrypted) { + cookieBase.push('Secure'); + } + const attrs = cookieBase.join('; '); + + const newCookies = [ + `meteor_login_token=${encodeURIComponent(stampedToken.token)}; ${attrs}`, + `meteor_user_id=${encodeURIComponent(userId)}; ${attrs}`, + `meteor_login_token_expires=${encodeURIComponent(tokenExpires.toISOString())}; ${attrs}`, + ]; + + const existingCookies = res.getHeader('Set-Cookie'); + if (Array.isArray(existingCookies)) { + res.setHeader('Set-Cookie', [...existingCookies, ...newCookies]); + } else if (typeof existingCookies === 'string' && existingCookies) { + res.setHeader('Set-Cookie', [existingCookies, ...newCookies]); + } else { + res.setHeader('Set-Cookie', newCookies); + } +} + Meteor.startup(() => { if (process.env.HEADER_LOGIN_ID) { - Meteor.settings.public.headerLoginId = process.env.HEADER_LOGIN_ID; - Meteor.settings.public.headerLoginEmail = process.env.HEADER_LOGIN_EMAIL; + Meteor.settings.public.headerLoginId = process.env.HEADER_LOGIN_ID; + Meteor.settings.public.headerLoginEmail = process.env.HEADER_LOGIN_EMAIL; Meteor.settings.public.headerLoginFirstname = process.env.HEADER_LOGIN_FIRSTNAME; - Meteor.settings.public.headerLoginLastname = process.env.HEADER_LOGIN_LASTNAME; + Meteor.settings.public.headerLoginLastname = process.env.HEADER_LOGIN_LASTNAME; + + const isSandstorm = Meteor.settings?.public?.sandstorm === true; + if (!isSandstorm) { + WebApp.handlers.use(async (req, res, next) => { + try { + if (!shouldProcessHeaderLoginMiddlewareRequest(req)) { + return next(); + } + + if (!isTrustedHeaderLoginSource(req)) { + return next(); + } + + const userId = await findOrCreateHeaderLoginUser(req); + if (userId) { + await issueLoginTokenCookies(userId, req, res); + } + } catch (error) { + if (process.env.DEBUG === 'true') { + console.warn('Header login middleware failed:', error); + } + } + return next(); + }); + } } }); diff --git a/server/lib/headerLoginAuth.js b/server/lib/headerLoginAuth.js new file mode 100644 index 000000000..0b683f296 --- /dev/null +++ b/server/lib/headerLoginAuth.js @@ -0,0 +1,157 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +function normalizeHeaderValue(value) { + if (Array.isArray(value)) { + return normalizeHeaderValue(value[0]); + } + if (typeof value !== 'string') { + return ''; + } + return value.trim(); +} + +function getHeaderByName(req, headerName) { + if (!headerName || !req?.headers) { + return ''; + } + const direct = req.headers[headerName]; + if (direct) { + return normalizeHeaderValue(direct); + } + const lower = req.headers[String(headerName).toLowerCase()]; + return normalizeHeaderValue(lower); +} + +function normalizeUsername(rawUsername) { + const value = normalizeHeaderValue(rawUsername) + .replace(/[\\/]/g, '-') + .replace(/\s+/g, ' ') + .trim(); + return value.slice(0, 64); +} + +function normalizeEmail(rawEmail) { + const value = normalizeHeaderValue(rawEmail).toLowerCase(); + if (!value || !value.includes('@') || value.includes(' ')) { + return ''; + } + return value.slice(0, 254); +} + +function getRequestIp(req) { + const forwardedFor = normalizeHeaderValue(req?.headers?.['x-forwarded-for']); + if (forwardedFor) { + return forwardedFor.split(',')[0].trim(); + } + return normalizeHeaderValue(req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''); +} + +function isTrustedHeaderLoginSource(req) { + const trustedRaw = normalizeHeaderValue(process.env.HEADER_LOGIN_TRUSTED_IP || process.env.HEADER_LOGIN_TRUSTED_IPS || ''); + if (!trustedRaw) { + return true; + } + + const trustedIps = trustedRaw + .split(',') + .map(ip => ip.trim()) + .filter(Boolean); + if (trustedIps.length === 0) { + return true; + } + + const requestIp = getRequestIp(req); + return !!requestIp && trustedIps.includes(requestIp); +} + +function hasMeteorLoginTokenCookie(req) { + const cookieHeader = normalizeHeaderValue(req?.headers?.cookie || ''); + if (!cookieHeader) { + return false; + } + return cookieHeader.split(';').some(part => { + const token = part.trim().split('=')[0]; + return token === 'meteor_login_token'; + }); +} + +function shouldProcessHeaderLoginMiddlewareRequest(req) { + if (req?.method !== 'GET') { + return false; + } + + const requestPath = (req?.url || '').split('?')[0] || ''; + if (requestPath.startsWith('/api/') || requestPath.startsWith('/sockjs/')) { + return false; + } + + if (hasMeteorLoginTokenCookie(req)) { + return false; + } + + return true; +} + +async function findOrCreateHeaderLoginUser(req) { + const isSandstorm = Meteor.settings?.public?.sandstorm === true; + if (isSandstorm) { + return null; + } + + const idHeaderName = process.env.HEADER_LOGIN_ID; + if (!idHeaderName) { + return null; + } + + if (!isTrustedHeaderLoginSource(req)) { + throw new Meteor.Error('unauthorized', 'Header login source is not trusted'); + } + + const usernameHeader = getHeaderByName(req, idHeaderName); + const username = normalizeUsername(usernameHeader); + if (!username) { + return null; + } + + const firstName = normalizeHeaderValue(getHeaderByName(req, process.env.HEADER_LOGIN_FIRSTNAME)); + const lastName = normalizeHeaderValue(getHeaderByName(req, process.env.HEADER_LOGIN_LASTNAME)); + const fullName = `${firstName} ${lastName}`.trim(); + const email = normalizeEmail(getHeaderByName(req, process.env.HEADER_LOGIN_EMAIL)); + + let user = await Meteor.users.findOneAsync({ username }); + if (!user && email) { + user = await Meteor.users.findOneAsync({ 'emails.address': email }); + } + + if (!user) { + const userDoc = { + username, + authenticationMethod: 'header', + profile: { + fullname: fullName || username, + }, + }; + + if (email) { + userDoc.emails = [{ address: email, verified: true }]; + } + + const newUserId = await Accounts.insertUserDoc({}, userDoc); + user = await Meteor.users.findOneAsync({ _id: newUserId }); + } + + return user?._id || null; +} + +export { + normalizeHeaderValue, + getHeaderByName, + normalizeUsername, + normalizeEmail, + getRequestIp, + isTrustedHeaderLoginSource, + hasMeteorLoginTokenCookie, + shouldProcessHeaderLoginMiddlewareRequest, + findOrCreateHeaderLoginUser, +};