1 parent
a37190e0fc
commit
d9f1ecb834
3 files changed
+277
-3
No files matched your search
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user