mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
🐛 fix(auth): 收紧 BFF 鉴权并修复内部调用链路
This commit is contained in:
@@ -209,7 +209,7 @@ npm run test:coverage # 生成覆盖率报告
|
||||
|
||||
1. **上下文检索优先级**: 开始任务或修改代码前,优先使用 `mcp__fast_context__fast_context_search` 做语义检索;再用 `rg`/`sed`/`nl` 精确核验文件、符号和行号
|
||||
2. **不要再引用旧的 `mcp__ace-tool__search_context`**: 该仓库当前统一使用 `mcp__fast_context__fast_context_search` 作为首选上下文检索方式
|
||||
3. **Giffgaff OAuth 的 `GIFFGAFF_REDIRECT_URI` 只影响服务端 token exchange**: 前端授权跳转仍由 `src/giffgaff/js/modules/oauth-handler.js` 中的 `oauthConfig.redirectUri` 控制,Netlify 环境变量不会自动改写前端授权 URL
|
||||
3. **Giffgaff OAuth 的 `GIFFGAFF_REDIRECT_URI` 只影响服务端 token exchange**: 前端授权跳转仍由 `src/giffgaff/js/modules/api-config.js` 中的 `oauthConfig.redirectUri` 控制,Netlify 环境变量不会自动改写前端授权 URL
|
||||
4. **修改 Functions 时**: 必须通过 `withAuth` 中间件包装 handler
|
||||
5. **修改前端模块时**: 使用相对路径导入模块
|
||||
6. **添加新 Function 时**: 在 `server.js` 中注册 Express 路由 (本地开发)
|
||||
|
||||
@@ -36,6 +36,10 @@ RECAPTCHA_SECRET_KEY=
|
||||
# ❌ 禁止使用默认值或简单密码
|
||||
ACCESS_KEY=
|
||||
|
||||
# Simyo 代理客户端令牌
|
||||
# ⚠️ 启用 Simyo 代理时必填;server.js 不再提供默认回退
|
||||
SIMYO_CLIENT_TOKEN=
|
||||
|
||||
# Giffgaff OAuth token exchange 回调 URI
|
||||
# 注意:该变量只影响服务端 token exchange;前端授权跳转 URI 仍在 src/giffgaff/js/modules/api-config.js 中配置。
|
||||
GIFFGAFF_REDIRECT_URI=giffgaff://auth/callback/
|
||||
|
||||
@@ -77,13 +77,16 @@ export default async (request, context) => {
|
||||
const sameOrigin = requestOrigin === url.origin;
|
||||
const configuredOrigin = allowedOrigins.includes(requestOrigin);
|
||||
const corsOrigin = sameOrigin || configuredOrigin ? requestOrigin : '';
|
||||
const allowMissingOriginForPublicGet = targetName === 'public-config' && request.method === 'GET';
|
||||
const fallbackCorsOrigin = allowedOrigins[0] || DEFAULT_ALLOWED_ORIGIN;
|
||||
const errorCorsHeaders = buildCorsHeaders(corsOrigin || fallbackCorsOrigin);
|
||||
|
||||
if (!corsOrigin) {
|
||||
if (!corsOrigin && !allowMissingOriginForPublicGet) {
|
||||
console.warn(`[BFF] ${ts} | blocked origin=${requestOrigin || 'missing'} target=${targetName}`);
|
||||
return jsonResponse(403, { error: 'Forbidden', message: 'Origin not allowed' });
|
||||
return jsonResponse(403, { error: 'Forbidden', message: 'Origin not allowed' }, errorCorsHeaders);
|
||||
}
|
||||
|
||||
const corsHeaders = buildCorsHeaders(corsOrigin);
|
||||
const corsHeaders = corsOrigin ? buildCorsHeaders(corsOrigin) : {};
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response('', { status: 200, headers: corsHeaders });
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ const schema = {
|
||||
- **axios**: HTTP 请求 (Functions 内部调用外部 API)
|
||||
- **cheerio**: HTML 解析 (爬虫场景)
|
||||
- **@sentry/node**: 错误监控 (生产环境)
|
||||
- **环境变量**: `ACCESS_KEY`, `ALLOWED_ORIGIN`, `GIFFGAFF_CLIENT_ID`, `GIFFGAFF_CLIENT_SECRET`, `GIFFGAFF_REDIRECT_URI`, `SENTRY_DSN`
|
||||
- **环境变量**: `ACCESS_KEY`, `ALLOWED_ORIGIN`, `GIFFGAFF_CLIENT_ID`, `GIFFGAFF_CLIENT_SECRET`, `GIFFGAFF_REDIRECT_URI`, `SIMYO_CLIENT_TOKEN`, `SENTRY_DSN`
|
||||
|
||||
## 数据模型
|
||||
|
||||
|
||||
@@ -77,12 +77,12 @@ function authenticate(event) {
|
||||
return { preflight: true, origin: requestOrigin };
|
||||
}
|
||||
|
||||
// 非预检请求:验证来源
|
||||
// 非预检请求:内部调用可跳过 Origin gate,但仍需在后续校验内部密钥
|
||||
if (!requestOrigin && !isInternalCall) {
|
||||
throw new AuthError('Origin not allowed', 403);
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(requestOrigin)) {
|
||||
if (requestOrigin && !isAllowedOrigin(requestOrigin)) {
|
||||
throw new AuthError('Origin not allowed', 403);
|
||||
}
|
||||
|
||||
@@ -296,17 +296,19 @@ function withAuth(handler, options = {}) {
|
||||
// 执行业务逻辑
|
||||
const result = await handler(event, context, { auth, body: parsedBody });
|
||||
|
||||
const responseOrigin = auth.origin;
|
||||
|
||||
// 确保返回正确的响应格式
|
||||
if (!result.statusCode) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: createHeaders(auth.origin),
|
||||
headers: createHeaders(responseOrigin),
|
||||
body: JSON.stringify(result)
|
||||
};
|
||||
}
|
||||
|
||||
// 合并默认头
|
||||
result.headers = createHeaders(auth.origin, result.headers || {});
|
||||
result.headers = createHeaders(responseOrigin, result.headers || {});
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,9 +7,12 @@ const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
function getInternalHeaders() {
|
||||
if (!process.env.ACCESS_KEY) {
|
||||
throw new AuthError('ACCESS_KEY 未配置', 500);
|
||||
}
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-esim-key': process.env.ACCESS_KEY || ''
|
||||
'x-esim-key': process.env.ACCESS_KEY
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,7 +197,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
if (r.data?.success && r.data?.accessToken) {
|
||||
if (r.data?.valid && r.data?.accessToken) {
|
||||
accessToken = r.data.accessToken;
|
||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
console.log(`[GGQL] ${ts} | token refreshed, retrying upstream call`);
|
||||
@@ -206,7 +209,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
console.log(`[GGQL] ${ts} | retry OK: status=${response.status}`);
|
||||
break; // 重试成功,跳出循环
|
||||
} else {
|
||||
console.error(`[GGQL] ${ts} | cookie refresh failed: success=${r.data?.success}`);
|
||||
console.error(`[GGQL] ${ts} | cookie refresh failed: valid=${r.data?.valid}`);
|
||||
throw err;
|
||||
}
|
||||
} catch (reErr) {
|
||||
|
||||
@@ -7,9 +7,12 @@ const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
function getInternalHeaders() {
|
||||
if (!process.env.ACCESS_KEY) {
|
||||
throw new AuthError('ACCESS_KEY 未配置', 500);
|
||||
}
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-esim-key': process.env.ACCESS_KEY || ''
|
||||
'x-esim-key': process.env.ACCESS_KEY
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +83,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
headers: getInternalHeaders(),
|
||||
timeout: 30000
|
||||
});
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) {
|
||||
accessToken = cookieVerifyResponse.data.accessToken;
|
||||
}
|
||||
} catch (cookieError) {
|
||||
@@ -185,7 +188,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) {
|
||||
let refreshed = cookieVerifyResponse.data.accessToken;
|
||||
const looksLikeJwt = typeof refreshed === 'string' && refreshed.includes('.') && refreshed.length > 200;
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
function getInternalHeaders() {
|
||||
if (!process.env.ACCESS_KEY) {
|
||||
throw new AuthError('ACCESS_KEY 未配置', 500);
|
||||
}
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-esim-key': process.env.ACCESS_KEY || ''
|
||||
'x-esim-key': process.env.ACCESS_KEY
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) {
|
||||
accessToken = cookieVerifyResponse.data.accessToken;
|
||||
}
|
||||
} catch (cookieError) {
|
||||
@@ -105,7 +108,7 @@ exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
if (cookieVerifyResponse.data?.valid && cookieVerifyResponse.data?.accessToken) {
|
||||
const refreshed = cookieVerifyResponse.data.accessToken;
|
||||
response = await sendValidation(refreshed);
|
||||
} else {
|
||||
|
||||
10
server.js
10
server.js
@@ -185,6 +185,14 @@ app.use('/api/simyo/*', (req, res) => {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const simyoClientToken = process.env.SIMYO_CLIENT_TOKEN;
|
||||
if (!simyoClientToken) {
|
||||
return res.status(500).json({
|
||||
error: 'Server Misconfigured',
|
||||
message: 'SIMYO_CLIENT_TOKEN 未配置'
|
||||
});
|
||||
}
|
||||
|
||||
// 代理请求
|
||||
const axios = require('axios');
|
||||
const config = {
|
||||
@@ -193,7 +201,7 @@ app.use('/api/simyo/*', (req, res) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': req.headers['user-agent'] || process.env.SIMYO_USER_AGENT || DEFAULT_SIMYO_USER_AGENT,
|
||||
'X-Client-Token': process.env.SIMYO_CLIENT_TOKEN || '',
|
||||
'X-Client-Token': simyoClientToken,
|
||||
'X-Client-Platform': process.env.SIMYO_CLIENT_PLATFORM || DEFAULT_SIMYO_CLIENT_PLATFORM,
|
||||
'X-Client-Version': process.env.SIMYO_CLIENT_VERSION || DEFAULT_SIMYO_CLIENT_VERSION,
|
||||
...(req.headers['x-session-token'] ? { 'X-Session-Token': req.headers['x-session-token'] } : {})
|
||||
|
||||
@@ -64,23 +64,26 @@ class APIService {
|
||||
* Execute the actual HTTP request with retry
|
||||
*/
|
||||
async executeRequest(url, options) {
|
||||
const timeout = this.createTimeoutSignal(options.timeout || this.timeout);
|
||||
const fetchOptions = {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
signal: timeout.signal
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
fetchOptions.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
return await retry(async () => {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
return await retry(async () => {
|
||||
const timeout = this.createTimeoutSignal(options.timeout || this.timeout);
|
||||
try {
|
||||
const requestOptions = {
|
||||
...fetchOptions,
|
||||
signal: timeout.signal
|
||||
};
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -92,10 +95,10 @@ class APIService {
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}, this.retries);
|
||||
} finally {
|
||||
timeout.clear();
|
||||
}
|
||||
} finally {
|
||||
timeout.clear();
|
||||
}
|
||||
}, this.retries);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,6 +103,31 @@ describe('APIService', () => {
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
|
||||
|
||||
it('应该为每次重试创建新的超时信号', async () => {
|
||||
const timeoutService = new APIService({ baseURL: 'https://api.test.com', timeout: 5000, retries: 2 });
|
||||
const firstSignal = { aborted: true };
|
||||
const secondSignal = { aborted: false };
|
||||
const createTimeoutSpy = jest.spyOn(timeoutService, 'createTimeoutSignal')
|
||||
.mockReturnValueOnce({ signal: firstSignal, clear: jest.fn() })
|
||||
.mockReturnValueOnce({ signal: secondSignal, clear: jest.fn() });
|
||||
|
||||
fetch.mockImplementationOnce(() => Promise.reject(new Error('timeout')));
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: () => Promise.resolve({ ok: true })
|
||||
});
|
||||
|
||||
const result = await timeoutService.get('/retry-timeout');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(createTimeoutSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetch.mock.calls[0][1].signal).toBe(firstSignal);
|
||||
expect(fetch.mock.calls[1][1].signal).toBe(secondSignal);
|
||||
});
|
||||
it('应该在 HTTP 错误时抛出异常', async () => {
|
||||
// mock fetch 返回 error response,但重试会多次调用
|
||||
fetch.mockResolvedValue({
|
||||
|
||||
@@ -3,6 +3,7 @@ describe('BFF proxy guardrails', () => {
|
||||
const OriginalRequest = global.Request;
|
||||
const OriginalResponse = global.Response;
|
||||
const OriginalDeno = global.Deno;
|
||||
const OriginalFetch = global.fetch;
|
||||
|
||||
class MockHeaders {
|
||||
constructor(init = {}) {
|
||||
@@ -92,6 +93,7 @@ describe('BFF proxy guardrails', () => {
|
||||
jest.restoreAllMocks();
|
||||
global.Request = OriginalRequest;
|
||||
global.Response = OriginalResponse;
|
||||
global.fetch = OriginalFetch;
|
||||
global.Deno = OriginalDeno;
|
||||
});
|
||||
|
||||
@@ -106,7 +108,21 @@ describe('BFF proxy guardrails', () => {
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('blocks missing-origin calls to protected BFF targets', async () => {
|
||||
|
||||
|
||||
it('allows same-origin public GET requests without Origin', async () => {
|
||||
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
|
||||
const request = new Request('https://example.com/bff/public-config', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const response = await mod.default(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 403 for missing-origin protected POST requests', async () => {
|
||||
const mod = await import('../../netlify/edge-functions/bff-proxy.js');
|
||||
const request = new Request('https://example.com/bff/verify-cookie', {
|
||||
method: 'POST',
|
||||
@@ -115,7 +131,9 @@ describe('BFF proxy guardrails', () => {
|
||||
});
|
||||
|
||||
const response = await mod.default(request);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('https://esim.cosr.eu.org');
|
||||
});
|
||||
|
||||
it('forwards allowlisted BFF targets with the server x-esim-key', async () => {
|
||||
|
||||
@@ -49,6 +49,32 @@ describe('Functions authentication hardening', () => {
|
||||
expect(result.authorized).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it('allows OPTIONS preflight for allowed origin', () => {
|
||||
const { middleware } = loadModules();
|
||||
|
||||
const result = middleware.authenticate({
|
||||
httpMethod: 'OPTIONS',
|
||||
headers: { origin: 'https://esim.cosr.eu.org' },
|
||||
body: '{}',
|
||||
queryStringParameters: {}
|
||||
});
|
||||
|
||||
expect(result.preflight).toBe(true);
|
||||
expect(result.origin).toBe('https://esim.cosr.eu.org');
|
||||
});
|
||||
|
||||
it('rejects OPTIONS preflight for disallowed origin', () => {
|
||||
const { middleware } = loadModules();
|
||||
|
||||
expect(() => middleware.authenticate({
|
||||
httpMethod: 'OPTIONS',
|
||||
headers: { origin: 'https://evil.example.com' },
|
||||
body: '{}',
|
||||
queryStringParameters: {}
|
||||
})).toThrow('Origin not allowed');
|
||||
});
|
||||
|
||||
it('does not accept authKey from query or body', () => {
|
||||
const { middleware } = loadModules();
|
||||
const event = {
|
||||
@@ -87,6 +113,27 @@ describe('Functions authentication hardening', () => {
|
||||
expect(form).not.toContain('attacker.example');
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('throws a configuration error when ACCESS_KEY is missing for token exchange', async () => {
|
||||
const { tokenExchange } = loadModules({ ACCESS_KEY: '' });
|
||||
|
||||
const result = await tokenExchange.handler({
|
||||
httpMethod: 'POST',
|
||||
headers: {
|
||||
origin: 'https://esim.cosr.eu.org'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: 'authorization-code',
|
||||
code_verifier: 'a'.repeat(64)
|
||||
}),
|
||||
queryStringParameters: {}
|
||||
}, { functionName: 'giffgaff-token-exchange' });
|
||||
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.body).toContain('ACCESS_KEY');
|
||||
});
|
||||
|
||||
it('uses configured GIFFGAFF_REDIRECT_URI for token exchange', async () => {
|
||||
const { axios, tokenExchange } = loadModules({
|
||||
GIFFGAFF_REDIRECT_URI: 'giffgaff://custom/callback/'
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('Local server route coverage', () => {
|
||||
const app = require('../../server.js');
|
||||
const routes = app.locals.bffRoutes;
|
||||
|
||||
expect(routes).toEqual(expect.arrayContaining([
|
||||
const expectedRoutes = [
|
||||
'/bff/giffgaff-token-exchange',
|
||||
'/bff/giffgaff-graphql',
|
||||
'/bff/giffgaff-mfa-challenge',
|
||||
@@ -33,6 +33,8 @@ describe('Local server route coverage', () => {
|
||||
'/bff/auto-activate-esim',
|
||||
'/bff/verify-cookie',
|
||||
'/bff/public-config'
|
||||
]));
|
||||
];
|
||||
|
||||
expect([...routes].sort()).toEqual([...expectedRoutes].sort());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user