Add Header Login.

Fixes #4311,
fixes #2019
This commit is contained in:
Lauri Ojansivu committed 2026-05-27 20:51:44 +03:00
1 parent a37190e0fc
commit d9f1ecb834
3 files changed
+277 -3

No files matched your search

+47
View File
@@ -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();
+73 -3
View File
@@ -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();
});
}
}
});
+157
View File
@@ -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,
};