🐛 fix(auth): 收紧 BFF 鉴权并修复内部调用链路

This commit is contained in:
Abner
2026-05-31 19:25:23 +08:00
parent b815225845
commit df2b6ca814
14 changed files with 152 additions and 31 deletions

View File

@@ -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 路由 (本地开发)

View File

@@ -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/

View File

@@ -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 });
}

View File

@@ -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`
## 数据模型

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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'] } : {})

View File

@@ -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);
}
/**

View File

@@ -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({

View File

@@ -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 () => {

View File

@@ -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/'

View File

@@ -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());
});
});