mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-08 17:08:59 +08:00
fix: refresh user access tokens from API errors without blocking mailbox access
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { WORKER_URL, WORKER_URL_SITE_PASSWORD } from '../../fixtures/test-helpers';
|
||||
|
||||
for (const scenario of ['expired role', 'valid role', 'invalid signature', 'missing expiry', 'admin password', 'wrong password'] as const) {
|
||||
test(`Admin role token errors remain distinct from password errors: ${scenario}`, async ({ request }) => {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
user_id: 1, user_role: 'admin',
|
||||
exp: scenario === 'missing expiry' ? undefined
|
||||
: Math.floor(Date.now() / 1000) + (scenario === 'valid role' || scenario === 'invalid signature' ? 3600 : -60),
|
||||
})).toString('base64url');
|
||||
const signature = createHmac('sha256', scenario === 'invalid signature' ? 'wrong-secret' : 'e2e-site-password-secret')
|
||||
.update(`${header}.${payload}`).digest('base64url');
|
||||
const headers: Record<string, string> = { 'x-custom-auth': 'e2e-site-pass', 'x-lang': 'en' };
|
||||
if (scenario !== 'wrong password') headers['x-user-access-token'] = `${header}.${payload}.${signature}`;
|
||||
if (scenario === 'admin password') headers['x-admin-auth'] = 'e2e-admin-pass';
|
||||
if (scenario === 'wrong password') headers['x-admin-auth'] = 'wrong-password';
|
||||
const response = await request.get(`${WORKER_URL_SITE_PASSWORD}/admin/db_version`, { headers });
|
||||
if (scenario === 'expired role') {
|
||||
expect(response.status()).toBe(401);
|
||||
expect(await response.json()).toEqual({
|
||||
code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||
message: 'Your access token has expired, please refresh the page',
|
||||
});
|
||||
} else if (scenario === 'missing expiry') {
|
||||
expect(response.status()).toBe(401);
|
||||
expect(await response.text()).toBe('Your access token has expired, please refresh the page');
|
||||
} else if (scenario === 'valid role' || scenario === 'admin password') {
|
||||
expect(response.ok()).toBe(true);
|
||||
} else {
|
||||
expect(response.status()).toBe(401);
|
||||
expect(await response.text()).toBe('You need to provide the admin password to access this page');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('generic authentication errors preserve their text messages and CORS headers', async ({ request }) => {
|
||||
for (const { path, message } of [
|
||||
{ path: '/api/settings', message: 'Invalid address credential' },
|
||||
{ path: '/user_api/settings', message: 'Your token has expired, please login again' },
|
||||
]) {
|
||||
const response = await request.get(`${WORKER_URL}${path}`, { headers: { 'x-lang': 'en' } });
|
||||
expect(response.status()).toBe(401);
|
||||
expect(response.headers()['content-type']).toContain('text/plain');
|
||||
expect(response.headers()['access-control-allow-origin']).toBe('*');
|
||||
expect(await response.text()).toBe(message);
|
||||
}
|
||||
});
|
||||
|
||||
test('uncaught server errors return JSON with the original error detail', async ({ request }) => {
|
||||
const response = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: Buffer.from('{invalid-json'),
|
||||
});
|
||||
expect(response.status()).toBe(500);
|
||||
expect(response.headers()['content-type']).toContain('application/json');
|
||||
expect(await response.json()).toEqual({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('SyntaxError') });
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress, hashPassword, getAddressSender, onMailpitMessage } from '../../fixtures/test-helpers';
|
||||
|
||||
const accessToken = (expiresIn: number) => {
|
||||
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresIn })).toString('base64url');
|
||||
return `e30.${payload}.signature`;
|
||||
};
|
||||
|
||||
const openApiTestPage = async (page: Page) => {
|
||||
// Isolate API calls from the application's automatic settings requests.
|
||||
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||
}));
|
||||
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||
};
|
||||
|
||||
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
|
||||
|
||||
for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'server error', 'json client error', 'json server error'] as const) {
|
||||
test(`Admin access token response handling: ${scenario}`, async ({ page }) => {
|
||||
await openApiTestPage(page);
|
||||
|
||||
const initialToken = accessToken(scenario === 'valid' ? 3600 : scenario === 'expiring' ? 20 : -60);
|
||||
const freshToken = accessToken(7200);
|
||||
let refreshCount = 0;
|
||||
const attempts: string[] = [];
|
||||
await page.route('**/user_api/settings', async (route) => {
|
||||
refreshCount++;
|
||||
expect(route.request().headers()['x-user-token']).toBe('account-token');
|
||||
await route.fulfill(scenario === 'login expired'
|
||||
? { status: 401, contentType: 'text/plain', body: 'Please login again' }
|
||||
: { json: { access_token: freshToken } });
|
||||
});
|
||||
await page.route('**/admin/db_version', async (route) => {
|
||||
attempts.push(route.request().headers()['x-user-access-token']);
|
||||
if (scenario.startsWith('json')) {
|
||||
await route.fulfill({
|
||||
status: scenario === 'json client error' ? 403 : 503,
|
||||
json: { code: 'OPERATION_FAILED', message: 'Operation failed' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (scenario === 'server error') {
|
||||
await route.fulfill({ status: 500, body: 'Server error' });
|
||||
return;
|
||||
}
|
||||
if (!['valid', 'expiring', 'wrong password'].includes(scenario)
|
||||
&& (attempts.length === 1 || scenario === 'retry expired')) {
|
||||
await route.fulfill(scenario.startsWith('text')
|
||||
? { status: 401, body: scenario === 'text zh' ? '您的访问令牌已过期, 请刷新页面' : 'Your access token has expired, please refresh the page' }
|
||||
: expiredTokenResponse);
|
||||
return;
|
||||
}
|
||||
await route.fulfill(['wrong password', 'retry unauthorized'].includes(scenario)
|
||||
? { status: 401, contentType: 'text/plain', body: 'Admin password required' }
|
||||
: { json: { current_db_version: 'test-version' } });
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async ({ initialToken, scenario }) => {
|
||||
const apiModule = '/src/api/index.js';
|
||||
const storeModule = '/src/store/index.js';
|
||||
const { api } = await import(apiModule);
|
||||
const state = (await import(storeModule)).useGlobalState();
|
||||
state.userJwt.value = scenario === 'no account' ? '' : 'account-token';
|
||||
state.userSettings.value.access_token = initialToken;
|
||||
state.adminAuth.value = scenario === 'wrong password' ? 'wrong-password' : '';
|
||||
state.showAdminAuth.value = false;
|
||||
try {
|
||||
return { data: await api.fetch('/admin/db_version'), error: null, showAdminAuth: state.showAdminAuth.value };
|
||||
} catch (error) {
|
||||
return { data: null, error: String(error), showAdminAuth: state.showAdminAuth.value };
|
||||
}
|
||||
}, { initialToken, scenario });
|
||||
|
||||
const needsRefresh = !['valid', 'expiring', 'no account', 'wrong password', 'text zh', 'text en', 'server error', 'json client error', 'json server error'].includes(scenario);
|
||||
expect(refreshCount).toBe(needsRefresh ? 1 : 0);
|
||||
expect(attempts).toEqual(needsRefresh && scenario !== 'login expired' ? [initialToken, freshToken] : [initialToken]);
|
||||
expect(result.showAdminAuth).toBe(['wrong password', 'retry unauthorized'].includes(scenario) || scenario.startsWith('text'));
|
||||
if (scenario === 'login expired') expect(result.error).toContain('Please login again');
|
||||
else if (['wrong password', 'retry unauthorized'].includes(scenario)) expect(result.error).toContain('Admin password required');
|
||||
else if (scenario === 'retry expired' || scenario === 'no account') expect(result.error).toContain('Access token expired');
|
||||
else if (scenario === 'server error') expect(result.error).toContain('Server error');
|
||||
else if (scenario.startsWith('text')) {
|
||||
expect(result.error).toContain(scenario === 'text zh'
|
||||
? '您的访问令牌已过期, 请刷新页面'
|
||||
: 'Your access token has expired, please refresh the page');
|
||||
}
|
||||
else if (scenario.startsWith('json')) {
|
||||
expect(result.error).toContain('Operation failed');
|
||||
expect(result.error).not.toContain('[object Object]');
|
||||
}
|
||||
else expect(result.data).toEqual({ current_db_version: 'test-version' });
|
||||
});
|
||||
}
|
||||
|
||||
test('Admin, mailbox and user requests share one access token refresh', async ({ page }) => {
|
||||
await openApiTestPage(page);
|
||||
const initialToken = accessToken(-60);
|
||||
const freshToken = accessToken(7200);
|
||||
const paths = ['/admin/db_version', '/api/settings', '/api/send_mail',
|
||||
'/user_api/bind_address', '/user_api/address/1/settings'];
|
||||
let refreshCount = 0;
|
||||
const attempts: string[] = [];
|
||||
let release!: () => void;
|
||||
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||
await page.route('**/user_api/settings', async (route) => {
|
||||
refreshCount++;
|
||||
await refreshPending;
|
||||
await route.fulfill({ json: { access_token: freshToken } });
|
||||
});
|
||||
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||
if (route.request().headers()['x-user-access-token'] === initialToken) {
|
||||
await route.fulfill(expiredTokenResponse);
|
||||
return;
|
||||
}
|
||||
attempts.push(route.request().headers()['x-user-access-token']);
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
const pending = page.evaluate(async ({ initialToken, paths }) => {
|
||||
const apiModule = '/src/api/index.js';
|
||||
const storeModule = '/src/store/index.js';
|
||||
const { api } = await import(apiModule);
|
||||
const state = (await import(storeModule)).useGlobalState();
|
||||
state.userJwt.value = 'account-token';
|
||||
state.userSettings.value.access_token = initialToken;
|
||||
return Promise.all(paths.map((path) => api.fetch(path, {
|
||||
method: path === '/api/send_mail' ? 'POST' : 'GET',
|
||||
})));
|
||||
}, { initialToken, paths });
|
||||
try {
|
||||
await expect.poll(() => refreshCount).toBe(1);
|
||||
expect(attempts).toEqual([]);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
expect(await pending).toEqual(paths.map(() => ({ success: true })));
|
||||
expect(refreshCount).toBe(1);
|
||||
expect(attempts).toEqual(paths.map(() => freshToken));
|
||||
});
|
||||
|
||||
for (const failure of [400, 401, 500, 'network'] as const) {
|
||||
test(`failed shared refresh preserves independent mailbox requests: ${failure}`, async ({ page }) => {
|
||||
await openApiTestPage(page);
|
||||
const paths = ['/admin/db_version', '/user_api/bind_address', '/user_api/address/1/settings',
|
||||
'/api/settings', '/api/send_mail'];
|
||||
const attempts: string[] = [];
|
||||
let initialResponses = 0;
|
||||
page.on('response', async (response) => {
|
||||
if (paths.includes(new URL(response.url()).pathname) && response.status() === 401) {
|
||||
await response.finished();
|
||||
initialResponses++;
|
||||
}
|
||||
});
|
||||
let refreshCount = 0;
|
||||
let release!: () => void;
|
||||
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||
const freshToken = accessToken(7200);
|
||||
await page.route('**/user_api/settings', async (route) => {
|
||||
refreshCount++;
|
||||
if (refreshCount > 1) {
|
||||
await route.fulfill({ json: { access_token: freshToken } });
|
||||
return;
|
||||
}
|
||||
await refreshPending;
|
||||
if (failure === 'network') await route.abort('failed');
|
||||
else await route.fulfill({ status: failure, body: 'Refresh failed' });
|
||||
});
|
||||
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (route.request().headers()['x-user-access-token'] && route.request().headers()['x-user-access-token'] !== freshToken) {
|
||||
await route.fulfill(expiredTokenResponse);
|
||||
return;
|
||||
}
|
||||
attempts.push(path);
|
||||
await route.fulfill(path === '/api/send_mail'
|
||||
? { status: 403, body: 'No send balance' }
|
||||
: { json: { success: true } });
|
||||
});
|
||||
const pending = page.evaluate(async ({ paths, initialToken }) => {
|
||||
const apiModule = '/src/api/index.js';
|
||||
const storeModule = '/src/store/index.js';
|
||||
const { api } = await import(apiModule);
|
||||
const state = (await import(storeModule)).useGlobalState();
|
||||
state.userJwt.value = 'account-token';
|
||||
state.userSettings.value.access_token = initialToken;
|
||||
state.adminAuth.value = '';
|
||||
state.openSettings.value.needAuth = true;
|
||||
state.showAuth.value = false;
|
||||
state.showAdminAuth.value = false;
|
||||
const results = await Promise.all(paths.map(async (path) => {
|
||||
try {
|
||||
return { data: await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' }) };
|
||||
} catch (error) {
|
||||
return { error: String(error) };
|
||||
}
|
||||
}));
|
||||
const flags = { showAuth: state.showAuth.value, showAdminAuth: state.showAdminAuth.value, loading: state.loading.value };
|
||||
await api.fetch('/admin/db_version');
|
||||
return { results, flags };
|
||||
}, { paths, initialToken: accessToken(-60) });
|
||||
try {
|
||||
await expect.poll(() => initialResponses).toBe(paths.length);
|
||||
await expect.poll(() => refreshCount).toBe(1);
|
||||
expect(attempts).toEqual([]);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
const { results, flags } = await pending;
|
||||
for (const result of results.slice(0, 3)) {
|
||||
expect(result.error).toContain(failure === 'network' ? 'Network Error' : 'Refresh failed');
|
||||
}
|
||||
expect(results[3].data).toEqual({ success: true });
|
||||
expect(results[4].error).toContain('[403]: No send balance');
|
||||
expect(flags).toEqual({ showAuth: false, showAdminAuth: false, loading: false });
|
||||
expect(refreshCount).toBe(2);
|
||||
expect(attempts.slice(0, 2).sort()).toEqual(['/api/send_mail', '/api/settings']);
|
||||
expect(attempts.slice(2)).toEqual(['/admin/db_version']);
|
||||
});
|
||||
}
|
||||
|
||||
test('a refresh cannot overwrite a changed account or send its waiting request', async ({ page }) => {
|
||||
await openApiTestPage(page);
|
||||
let attempts = 0;
|
||||
await page.route('**/admin/db_version', async (route) => {
|
||||
if (route.request().headers()['x-user-access-token'] !== 'other-access-token') {
|
||||
await route.fulfill(expiredTokenResponse);
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route('**/user_api/settings', async (route) => {
|
||||
await page.evaluate(async () => {
|
||||
const storeModule = '/src/store/index.js';
|
||||
const state = (await import(storeModule)).useGlobalState();
|
||||
state.userJwt.value = 'other-account';
|
||||
state.userSettings.value = { access_token: 'other-access-token' };
|
||||
});
|
||||
await route.fulfill({ json: { access_token: accessToken(7200) } });
|
||||
});
|
||||
const result = await page.evaluate(async (initialToken) => {
|
||||
const apiModule = '/src/api/index.js';
|
||||
const storeModule = '/src/store/index.js';
|
||||
const { api } = await import(apiModule);
|
||||
const state = (await import(storeModule)).useGlobalState();
|
||||
state.userJwt.value = 'account-token';
|
||||
state.userSettings.value.access_token = initialToken;
|
||||
state.adminAuth.value = '';
|
||||
try {
|
||||
await api.fetch('/admin/db_version');
|
||||
return null;
|
||||
} catch (error) {
|
||||
return { error: String(error), token: state.userSettings.value.access_token, loading: state.loading.value };
|
||||
}
|
||||
}, accessToken(-60));
|
||||
expect(result).toEqual({ error: 'Error: User session changed, please retry', token: 'other-access-token', loading: false });
|
||||
expect(attempts).toBe(0);
|
||||
});
|
||||
|
||||
for (const scenario of ['expired account', 'deleted account', 'valid account', 'missing account', 'missing expiry'] as const) {
|
||||
test(`mailbox operations use the real Worker when ${scenario}`, async ({ page, request }) => {
|
||||
const address = await createTestAddress(request, 'refresh-mailbox-');
|
||||
let userId: number | undefined;
|
||||
try {
|
||||
const email = `refresh-account-${Date.now()}@test.example.com`;
|
||||
const created = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
data: { email, password: hashPassword('test-password-123') },
|
||||
});
|
||||
expect(created.ok()).toBe(true);
|
||||
const users = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: 10, offset: 0, query: email },
|
||||
});
|
||||
expect(users.ok()).toBe(true);
|
||||
userId = (await users.json()).results.find((user: any) => user.user_email === email).id;
|
||||
|
||||
const sign = (payload: Record<string, unknown>) => {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = createHmac('sha256', 'e2e-test-secret-key').update(`${header}.${body}`).digest('base64url');
|
||||
return `${header}.${body}.${signature}`;
|
||||
};
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims = { user_id: userId, user_email: email, iat: now - 3600 };
|
||||
const userJwt = scenario === 'missing account' ? ''
|
||||
: sign({ ...claims, exp: now + (scenario === 'expired account' ? -60 : 3600) });
|
||||
const expiredAccessToken = sign({ ...claims, exp: scenario === 'missing expiry' ? undefined : now - 60, user_role: 'case-role' });
|
||||
if (scenario === 'deleted account') {
|
||||
expect((await request.delete(`${WORKER_URL}/admin/users/${userId}`)).ok()).toBe(true);
|
||||
}
|
||||
|
||||
// Exercise the browser API module without the UI's automatic settings requests.
|
||||
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||
}));
|
||||
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||
const refreshStatuses: number[] = [];
|
||||
let sendCount = 0;
|
||||
page.on('response', (response) => {
|
||||
if (new URL(response.url()).pathname === '/user_api/settings') refreshStatuses.push(response.status());
|
||||
});
|
||||
page.on('request', (req) => {
|
||||
if (new URL(req.url()).pathname === '/api/send_mail') sendCount++;
|
||||
});
|
||||
const settings = await page.evaluate(async ({ userJwt, expiredAccessToken, mailboxJwt }) => {
|
||||
const apiModule = '/src/api/index.js';
|
||||
const storeModule = '/src/store/index.js';
|
||||
const { api } = await import(apiModule);
|
||||
const state = (await import(storeModule)).useGlobalState();
|
||||
state.userJwt.value = userJwt;
|
||||
state.userSettings.value.access_token = expiredAccessToken;
|
||||
state.jwt.value = mailboxJwt;
|
||||
state.adminAuth.value = '';
|
||||
return api.fetch('/api/settings');
|
||||
}, { userJwt, expiredAccessToken, mailboxJwt: address.jwt });
|
||||
expect(settings.address).toBe(address.address);
|
||||
expect(settings.send_balance).toBe(10);
|
||||
|
||||
const subject = `Mailbox refresh regression ${scenario} ${Date.now()}`;
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||
await listener.ready;
|
||||
const [mail] = await Promise.all([
|
||||
listener.message,
|
||||
page.evaluate(async (subject) => {
|
||||
const apiModule = '/src/api/index.js';
|
||||
const { api } = await import(apiModule);
|
||||
await api.fetch('/api/send_mail', {
|
||||
method: 'POST',
|
||||
body: { to_mail: 'recipient@test.example.com', subject, content: 'Independent mailbox credential', is_html: false },
|
||||
});
|
||||
}, subject),
|
||||
]);
|
||||
expect(mail.From.Address).toBe(address.address);
|
||||
expect(sendCount).toBe(scenario === 'valid account' || scenario === 'missing expiry' ? 1 : 2);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(9);
|
||||
const sent = await request.get(`${WORKER_URL}/admin/sendbox`, {
|
||||
params: { address: address.address, limit: 10, offset: 0 },
|
||||
});
|
||||
expect(sent.ok()).toBe(true);
|
||||
expect((await sent.json()).results).toHaveLength(1);
|
||||
expect(refreshStatuses).toEqual(scenario === 'valid account' ? [200]
|
||||
: scenario === 'missing account' || scenario === 'missing expiry' ? []
|
||||
: scenario === 'expired account' ? [401, 401] : [400, 400]);
|
||||
} finally {
|
||||
await deleteAddress(request, address.jwt);
|
||||
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ErrorCode = {
|
||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||
};
|
||||
+29
-14
@@ -7,6 +7,7 @@ import { getFingerprint } from '../utils/fingerprint'
|
||||
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
||||
import { sanitizeHtml } from '../utils/sanitize-html'
|
||||
import { APP_CONFIG } from '../config'
|
||||
import { isUserAccessTokenError, canRetryUserAccessTokenRequest, retryUserAccessTokenRequest } from './user-access-token'
|
||||
|
||||
const API_BASE = APP_CONFIG.API_BASE || "";
|
||||
const {
|
||||
@@ -21,6 +22,11 @@ const instance = axios.create({
|
||||
validateStatus: (status) => status >= 200 && status <= 500
|
||||
});
|
||||
|
||||
const responseInterceptors = [{
|
||||
matches: canRetryUserAccessTokenRequest,
|
||||
handle: retryUserAccessTokenRequest,
|
||||
}];
|
||||
|
||||
const apiFetch = async (path, options = {}) => {
|
||||
const showLoading = options.showLoading !== false;
|
||||
if (showLoading) loading.value = true;
|
||||
@@ -47,25 +53,34 @@ const apiFetch = async (path, options = {}) => {
|
||||
const authorizationHeader = safeBearerHeader(jwt.value);
|
||||
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
||||
|
||||
const response = await instance.request(path, {
|
||||
const handleResponse = async (response, allowInterception = true) => {
|
||||
if (allowInterception) {
|
||||
const interceptor = responseInterceptors.find(({ matches }) => matches(response));
|
||||
if (interceptor) {
|
||||
return await handleResponse(await interceptor.handle(instance, response), false);
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response)) {
|
||||
showAdminAuth.value = true;
|
||||
}
|
||||
if (response.status === 401 && openSettings.value.needAuth && !isUserAccessTokenError(response)) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||
}
|
||||
const data = response.data;
|
||||
return data;
|
||||
};
|
||||
|
||||
return await handleResponse(await instance.request(path, {
|
||||
method: options.method || 'GET',
|
||||
data: options.body || null,
|
||||
headers,
|
||||
});
|
||||
if (response.status === 401 && path.startsWith("/admin")) {
|
||||
showAdminAuth.value = true;
|
||||
}
|
||||
if (response.status === 401 && openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
||||
}
|
||||
const data = response.data;
|
||||
return data;
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
|
||||
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AxiosHeaders } from 'axios';
|
||||
import { useGlobalState } from '../store';
|
||||
import { safeBearerHeader, safeHeaderValue } from '../utils/headers';
|
||||
import { SingleFlight } from '../utils/single-flight';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
const { userJwt, userSettings, jwt } = useGlobalState();
|
||||
const mailboxPaths = new Set(['/api/settings', '/api/send_mail']);
|
||||
const refreshRequests = new SingleFlight();
|
||||
|
||||
export const isUserAccessTokenError = (response) => response.status === 401
|
||||
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED;
|
||||
|
||||
export const canRetryUserAccessTokenRequest = (response) => {
|
||||
if (!isUserAccessTokenError(response)) return false;
|
||||
const { config } = response;
|
||||
if (config.url === '/user_api/settings') return false;
|
||||
if (!safeHeaderValue(config.headers.get('x-user-access-token'))) return false;
|
||||
|
||||
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||
if (token !== safeHeaderValue(userJwt.value)) return false;
|
||||
if (safeHeaderValue(config.headers.get('Authorization')) !== safeBearerHeader(jwt.value)) return false;
|
||||
return Boolean(token) || mailboxPaths.has(config.url);
|
||||
};
|
||||
|
||||
async function loadUserSettings(token, client, headers) {
|
||||
const response = await client.get('/user_api/settings', { headers });
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||
}
|
||||
if (safeHeaderValue(userJwt.value) === token) {
|
||||
Object.assign(userSettings.value, response.data);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRetryHeaders(client, config) {
|
||||
const headers = new AxiosHeaders(config.headers);
|
||||
headers.delete('x-user-access-token');
|
||||
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||
if (!token) return headers;
|
||||
|
||||
const accessToken = safeHeaderValue(userSettings.value.access_token);
|
||||
if (accessToken === safeHeaderValue(config.headers.get('x-user-access-token'))) {
|
||||
try {
|
||||
await refreshRequests.run(token, () => loadUserSettings(token, client, config.headers));
|
||||
} catch (error) {
|
||||
if (!mailboxPaths.has(config.url)) throw error;
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
const currentToken = safeHeaderValue(userSettings.value.access_token);
|
||||
if (currentToken) headers.set('x-user-access-token', currentToken);
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const retryUserAccessTokenRequest = async (client, response) => {
|
||||
const { config } = response;
|
||||
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||
const headers = await prepareRetryHeaders(client, config);
|
||||
if (token !== safeHeaderValue(userJwt.value)
|
||||
|| safeHeaderValue(config.headers.get('Authorization')) !== safeBearerHeader(jwt.value)) {
|
||||
throw new Error('User session changed, please retry');
|
||||
}
|
||||
return await client.request({ ...config, headers });
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export class SingleFlight {
|
||||
#pending = new Map();
|
||||
|
||||
async run(key, task) {
|
||||
if (this.#pending.has(key)) return await this.#pending.get(key);
|
||||
|
||||
const result = task();
|
||||
this.#pending.set(key, result);
|
||||
try {
|
||||
return await result;
|
||||
} finally {
|
||||
this.#pending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ErrorCode {
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
AUTH_USER_ACCESS_TOKEN_EXPIRED = 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||
}
|
||||
+12
-8
@@ -13,6 +13,7 @@ import { api as telegramApi } from './telegram_api'
|
||||
import { api as redeemApi } from './redeem_api'
|
||||
|
||||
import i18n from './i18n';
|
||||
import { ErrorCode } from './error_codes';
|
||||
import { email } from './email';
|
||||
import { scheduled } from './scheduled';
|
||||
import { getPasswords, getBooleanValue, getDomains, checkIsAdmin, getEnvStringList } from './utils';
|
||||
@@ -34,7 +35,7 @@ app.use('/*', cors());
|
||||
// error handler
|
||||
app.onError((err, c) => {
|
||||
console.error(err)
|
||||
return c.text(`${err.name} ${err.message}`, 500)
|
||||
return c.json({ code: ErrorCode.INTERNAL_SERVER_ERROR, message: `${err.name} ${err.message}` }, 500)
|
||||
})
|
||||
// global middlewares
|
||||
app.use('/*', async (c, next) => {
|
||||
@@ -134,16 +135,16 @@ const checkUserPayload = async (
|
||||
const checkoutUserRolePayload = async (
|
||||
c: Context<HonoCustomType>,
|
||||
userId?: number
|
||||
): Promise<void> => {
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const token = c.req.raw.headers.get("x-user-access-token");
|
||||
if (!token) return;
|
||||
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
|
||||
const payload = await Jwt.verify(token, c.env.JWT_SECRET, { alg: "HS256", exp: false });
|
||||
// check expired
|
||||
if (!payload.exp) return;
|
||||
// exp is in seconds
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
return;
|
||||
return c.json({ code: ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED, message: i18n.getMessagesbyContext(c).UserAcceesTokenExpiredMsg }, 401);
|
||||
}
|
||||
if (typeof payload?.user_role !== "string") return;
|
||||
if (userId !== undefined && payload.user_id !== userId) return;
|
||||
@@ -163,7 +164,8 @@ app.use('/api/*', async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/settings")
|
||||
|| c.req.path.startsWith("/api/send_mail")
|
||||
) {
|
||||
await checkoutUserRolePayload(c);
|
||||
const response = await checkoutUserRolePayload(c);
|
||||
if (response) return response;
|
||||
}
|
||||
if (c.req.path.startsWith("/api/address_login")) {
|
||||
await next();
|
||||
@@ -216,7 +218,8 @@ app.use('/user_api/*', async (c, next) => {
|
||||
|| c.req.path.startsWith("/user_api/address/")
|
||||
) {
|
||||
const { user_id } = c.get("userPayload");
|
||||
await checkoutUserRolePayload(c, user_id);
|
||||
const response = await checkoutUserRolePayload(c, user_id);
|
||||
if (response) return response;
|
||||
}
|
||||
if (c.req.path.startsWith('/user_api/bind_address')
|
||||
&& c.req.method === 'POST'
|
||||
@@ -253,12 +256,13 @@ app.use('/admin/*', async (c, next) => {
|
||||
const access_token = c.req.raw.headers.get("x-user-access-token");
|
||||
if (c.env.ADMIN_USER_ROLE && access_token) {
|
||||
try {
|
||||
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, "HS256");
|
||||
const payload = await Jwt.verify(access_token, c.env.JWT_SECRET, { alg: "HS256", exp: false });
|
||||
// check expired
|
||||
if (!payload.exp) return c.text(msgs.UserAcceesTokenExpiredMsg, 401);
|
||||
// exp is in seconds
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
return c.text(msgs.UserAcceesTokenExpiredMsg, 401)
|
||||
if (getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)) return await next();
|
||||
return c.json({ code: ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED, message: msgs.UserAcceesTokenExpiredMsg }, 401);
|
||||
}
|
||||
if (payload.user_role !== c.env.ADMIN_USER_ROLE) {
|
||||
return c.text(msgs.UserRoleIsNotAdminMsg, 401)
|
||||
|
||||
Reference in New Issue
Block a user