mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-06 07:47:27 +08:00
359 lines
10 KiB
JavaScript
359 lines
10 KiB
JavaScript
/**
|
||
* Netlify Functions 统一中间件
|
||
* 提供鉴权、CORS、错误处理等功能
|
||
*/
|
||
|
||
const { captureException, flush, setContext } = require('./sentry');
|
||
const { parseOrigins, isAllowedOrigin: _isAllowedOrigin, resolveCorsOrigin: _resolveCorsOrigin } = require('./cors');
|
||
|
||
const ACCESS_KEY = process.env.ACCESS_KEY;
|
||
const origins = parseOrigins(process.env.ALLOWED_ORIGIN);
|
||
|
||
// 启动时检查密钥
|
||
if (!ACCESS_KEY) {
|
||
console.error('❌ 严重错误: ACCESS_KEY 未配置');
|
||
console.error('💡 请在 Netlify 环境变量或 .env 文件中设置');
|
||
}
|
||
|
||
if (ACCESS_KEY === 'please_change_me') {
|
||
console.error('❌ 安全警告: ACCESS_KEY 使用了默认值,请立即修改');
|
||
}
|
||
|
||
if (origins.allowAll) {
|
||
console.warn('⚠️ ALLOWED_ORIGIN 包含通配符(*),所有来源均可访问。请勿在生产环境使用');
|
||
}
|
||
|
||
/**
|
||
* 自定义错误类
|
||
*/
|
||
class AuthError extends Error {
|
||
constructor(message, statusCode = 401) {
|
||
super(message);
|
||
this.name = 'AuthError';
|
||
this.statusCode = statusCode;
|
||
}
|
||
}
|
||
|
||
const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins);
|
||
const resolveCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins);
|
||
|
||
/**
|
||
* 提取请求中提供的认证密钥
|
||
* @param {Object} event - Netlify event 对象
|
||
* @returns {string} 认证密钥
|
||
*/
|
||
function getProvidedKey(event) {
|
||
const lower = Object.fromEntries(
|
||
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||
);
|
||
|
||
// 仅允许 Header 传递内部密钥,避免 URL / Body 泄漏
|
||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'];
|
||
if (fromHeader) return fromHeader;
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 验证请求鉴权
|
||
* @param {Object} event - Netlify event 对象
|
||
* @returns {Object} 鉴权结果 { authorized: boolean, origin: string, preflight: boolean }
|
||
* @throws {AuthError} 鉴权失败时抛出
|
||
*/
|
||
function authenticate(event) {
|
||
const lower = Object.fromEntries(
|
||
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||
);
|
||
const requestOrigin = lower.origin;
|
||
const requestMethod = event.httpMethod || 'GET';
|
||
const isInternalCall = lower['x-esim-key'] || lower['x-app-key'];
|
||
|
||
// CORS 预检请求
|
||
if (requestMethod === 'OPTIONS') {
|
||
// 验证来源
|
||
if (!isAllowedOrigin(requestOrigin)) {
|
||
throw new AuthError('Origin not allowed', 403);
|
||
}
|
||
return { preflight: true, origin: requestOrigin };
|
||
}
|
||
|
||
// 非预检请求:内部调用可跳过 Origin gate,但仍需在后续校验内部密钥
|
||
if (!requestOrigin && !isInternalCall) {
|
||
throw new AuthError('Origin not allowed', 403);
|
||
}
|
||
|
||
if (requestOrigin && !isAllowedOrigin(requestOrigin)) {
|
||
throw new AuthError('Origin not allowed', 403);
|
||
}
|
||
|
||
// 验证 ACCESS_KEY 是否已配置
|
||
if (!ACCESS_KEY) {
|
||
throw new AuthError('Server Misconfigured: ACCESS_KEY not configured', 500);
|
||
}
|
||
|
||
// 提取并验证密钥
|
||
const providedKey = getProvidedKey(event);
|
||
if (!providedKey || providedKey !== ACCESS_KEY) {
|
||
throw new AuthError('Unauthorized: Missing or invalid auth key', 401);
|
||
}
|
||
|
||
return { authorized: true, origin: requestOrigin };
|
||
}
|
||
|
||
/**
|
||
* 生成标准响应头
|
||
* @param {string} origin - 请求来源
|
||
* @param {Object} additionalHeaders - 额外的响应头
|
||
* @returns {Object} 响应头对象
|
||
*/
|
||
function createHeaders(origin = null, additionalHeaders = {}) {
|
||
return {
|
||
'Access-Control-Allow-Origin': resolveCorsOrigin(origin),
|
||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-MFA-Signature, X-MFA-Ref, X-MFA-Challenge-Ref, X-CF-Turnstile, X-Esim-Key, X-App-Key',
|
||
'Access-Control-Allow-Methods': 'POST, OPTIONS, GET',
|
||
'Vary': 'Origin',
|
||
'Content-Type': 'application/json',
|
||
'X-Content-Type-Options': 'nosniff',
|
||
'X-Frame-Options': 'DENY',
|
||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||
...additionalHeaders
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 统一错误处理
|
||
* @param {Error} error - 错误对象
|
||
* @param {string} context - 错误上下文
|
||
* @returns {Object} Netlify response 对象
|
||
*/
|
||
function handleError(error, context = 'unknown') {
|
||
const isDev = process.env.NODE_ENV === 'development';
|
||
const statusCode = error.statusCode || (error.response?.status) || 500;
|
||
|
||
// 结构化日志(生产环境应集成专业日志服务)
|
||
const logData = {
|
||
context,
|
||
message: error.message,
|
||
status: statusCode,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
if (isDev) {
|
||
logData.stack = error.stack;
|
||
logData.data = error.response?.data;
|
||
}
|
||
|
||
console.error(`[${context}] Error:`, JSON.stringify(logData));
|
||
|
||
// Sentry 错误上报(仅上报 5xx 服务端错误,4xx 客户端错误不上报)
|
||
if (statusCode >= 500) {
|
||
setContext('request', { context, statusCode });
|
||
captureException(error, {
|
||
extra: {
|
||
context,
|
||
statusCode,
|
||
responseData: error.response?.data
|
||
}
|
||
});
|
||
}
|
||
|
||
return {
|
||
statusCode,
|
||
headers: createHeaders(),
|
||
body: JSON.stringify({
|
||
error: error.name || 'ServerError',
|
||
message: error.message,
|
||
...(isDev && { stack: error.stack, context })
|
||
})
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 输入验证中间件
|
||
* @param {Object} schema - 验证规则对象
|
||
* @param {Object} data - 待验证数据
|
||
* @throws {AuthError} 验证失败时抛出
|
||
*/
|
||
function validateInput(schema, data) {
|
||
const errors = [];
|
||
|
||
Object.entries(schema).forEach(([key, rules]) => {
|
||
const value = data[key];
|
||
|
||
// required 检查
|
||
if (rules.required && (value === undefined || value === null || value === '')) {
|
||
errors.push(`${key} is required`);
|
||
return;
|
||
}
|
||
|
||
// 如果值为空且非必填,跳过后续验证
|
||
if (!rules.required && (value === undefined || value === null)) {
|
||
return;
|
||
}
|
||
|
||
// type 检查
|
||
if (rules.type && typeof value !== rules.type) {
|
||
errors.push(`${key} must be of type ${rules.type}`);
|
||
}
|
||
|
||
// minLength 检查
|
||
if (rules.minLength && value.length < rules.minLength) {
|
||
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||
}
|
||
|
||
// maxLength 检查
|
||
if (rules.maxLength && value.length > rules.maxLength) {
|
||
errors.push(`${key} must not exceed ${rules.maxLength} characters`);
|
||
}
|
||
|
||
// pattern 检查
|
||
if (rules.pattern && !rules.pattern.test(value)) {
|
||
errors.push(`${key} has invalid format`);
|
||
}
|
||
|
||
// enum 检查
|
||
if (rules.enum && !rules.enum.includes(value)) {
|
||
errors.push(`${key} must be one of: ${rules.enum.join(', ')}`);
|
||
}
|
||
});
|
||
|
||
if (errors.length > 0) {
|
||
const error = new AuthError(`Validation failed: ${errors.join('; ')}`, 400);
|
||
error.validationErrors = errors;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 包装 Function Handler,自动处理鉴权和错误
|
||
* @param {Function} handler - 业务逻辑处理函数
|
||
* @param {Object} options - 配置选项
|
||
* @returns {Function} 包装后的 handler
|
||
*/
|
||
function withAuth(handler, options = {}) {
|
||
return async (event, context) => {
|
||
const functionName = context.functionName || 'unknown';
|
||
|
||
try {
|
||
// 鉴权(requireAuth: false 时跳过密钥校验,仅保留 CORS 检查)
|
||
let auth;
|
||
if (options.requireAuth === false) {
|
||
const lower = Object.fromEntries(
|
||
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||
);
|
||
const requestOrigin = lower.origin;
|
||
|
||
// CORS 预检请求
|
||
if (event.httpMethod === 'OPTIONS') {
|
||
if (!isAllowedOrigin(requestOrigin)) {
|
||
throw new AuthError('Origin not allowed', 403);
|
||
}
|
||
return {
|
||
statusCode: 200,
|
||
headers: createHeaders(requestOrigin),
|
||
body: ''
|
||
};
|
||
}
|
||
|
||
// 非预检请求:仅验证来源
|
||
if (!isAllowedOrigin(requestOrigin)) {
|
||
throw new AuthError('Origin not allowed', 403);
|
||
}
|
||
|
||
auth = { authorized: false, origin: requestOrigin, public: true };
|
||
} else {
|
||
auth = authenticate(event);
|
||
}
|
||
|
||
// 预检请求直接返回(仅 requireAuth=true 分支到达此处)
|
||
if (auth.preflight) {
|
||
return {
|
||
statusCode: 200,
|
||
headers: createHeaders(auth.origin),
|
||
body: ''
|
||
};
|
||
}
|
||
|
||
// 解析请求体
|
||
let parsedBody = {};
|
||
if (event.body) {
|
||
try {
|
||
parsedBody = JSON.parse(event.body);
|
||
// 如果是数组格式,取第一个元素(兼容某些客户端)
|
||
if (Array.isArray(parsedBody)) {
|
||
parsedBody = parsedBody[0] || {};
|
||
}
|
||
} catch (e) {
|
||
throw new AuthError('Invalid JSON body', 400);
|
||
}
|
||
}
|
||
|
||
// 输入验证(如果提供了 schema)
|
||
if (options.validateSchema) {
|
||
validateInput(options.validateSchema, parsedBody);
|
||
}
|
||
|
||
// 执行业务逻辑
|
||
const result = await handler(event, context, { auth, body: parsedBody });
|
||
|
||
const responseOrigin = auth.origin;
|
||
|
||
// 确保返回正确的响应格式
|
||
if (!result.statusCode) {
|
||
return {
|
||
statusCode: 200,
|
||
headers: createHeaders(responseOrigin),
|
||
body: JSON.stringify(result)
|
||
};
|
||
}
|
||
|
||
// 合并默认头
|
||
result.headers = createHeaders(responseOrigin, result.headers || {});
|
||
return result;
|
||
|
||
} catch (error) {
|
||
const response = handleError(error, functionName);
|
||
// Serverless 环境需要显式 flush 确保错误发送到 Sentry
|
||
await flush(2000);
|
||
return response;
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 创建带超时的 fetch 包装器
|
||
* @param {string} url - 请求 URL
|
||
* @param {Object} options - fetch 选项
|
||
* @param {number} timeout - 超时时间(毫秒)
|
||
* @returns {Promise<Response>}
|
||
*/
|
||
async function fetchWithTimeout(url, options = {}, timeout = 30000) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
...options,
|
||
signal: controller.signal
|
||
});
|
||
clearTimeout(timeoutId);
|
||
return response;
|
||
} catch (error) {
|
||
clearTimeout(timeoutId);
|
||
if (error.name === 'AbortError') {
|
||
throw new Error(`Request timeout after ${timeout}ms`);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
authenticate,
|
||
createHeaders,
|
||
handleError,
|
||
validateInput,
|
||
withAuth,
|
||
fetchWithTimeout,
|
||
AuthError
|
||
};
|