From ae264ff0edd102b1cb6a523b7f35792249f10f96 Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Mon, 7 Sep 2026 22:33:09 +0800 Subject: [PATCH] fix: refresh user access tokens from API errors without blocking mailbox access --- e2e/tests/api/user-access-token.spec.ts | 59 ++++ e2e/tests/browser/user-access-token.spec.ts | 349 ++++++++++++++++++++ frontend/src/api/error-codes.js | 4 + frontend/src/api/index.js | 43 ++- frontend/src/api/user-access-token.js | 66 ++++ frontend/src/utils/single-flight.js | 15 + worker/src/error_codes.ts | 4 + worker/src/worker.ts | 20 +- 8 files changed, 538 insertions(+), 22 deletions(-) create mode 100644 e2e/tests/api/user-access-token.spec.ts create mode 100644 e2e/tests/browser/user-access-token.spec.ts create mode 100644 frontend/src/api/error-codes.js create mode 100644 frontend/src/api/user-access-token.js create mode 100644 frontend/src/utils/single-flight.js create mode 100644 worker/src/error_codes.ts diff --git a/e2e/tests/api/user-access-token.spec.ts b/e2e/tests/api/user-access-token.spec.ts new file mode 100644 index 0000000..5214c15 --- /dev/null +++ b/e2e/tests/api/user-access-token.spec.ts @@ -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 = { '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') }); +}); diff --git a/e2e/tests/browser/user-access-token.spec.ts b/e2e/tests/browser/user-access-token.spec.ts new file mode 100644 index 0000000..6351533 --- /dev/null +++ b/e2e/tests/browser/user-access-token.spec.ts @@ -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: '', + })); + 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((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((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) => { + 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: '', + })); + 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}`); + } + }); +} diff --git a/frontend/src/api/error-codes.js b/frontend/src/api/error-codes.js new file mode 100644 index 0000000..b031fa1 --- /dev/null +++ b/frontend/src/api/error-codes.js @@ -0,0 +1,4 @@ +export const ErrorCode = { + INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', + AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', +}; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 51bef3f..41faed9 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 { diff --git a/frontend/src/api/user-access-token.js b/frontend/src/api/user-access-token.js new file mode 100644 index 0000000..2394c08 --- /dev/null +++ b/frontend/src/api/user-access-token.js @@ -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 }); +}; diff --git a/frontend/src/utils/single-flight.js b/frontend/src/utils/single-flight.js new file mode 100644 index 0000000..1b3f268 --- /dev/null +++ b/frontend/src/utils/single-flight.js @@ -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); + } + } +} diff --git a/worker/src/error_codes.ts b/worker/src/error_codes.ts new file mode 100644 index 0000000..bb32ef9 --- /dev/null +++ b/worker/src/error_codes.ts @@ -0,0 +1,4 @@ +export enum ErrorCode { + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + AUTH_USER_ACCESS_TOKEN_EXPIRED = 'AUTH_USER_ACCESS_TOKEN_EXPIRED', +} diff --git a/worker/src/worker.ts b/worker/src/worker.ts index a106db9..73ee836 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -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, userId?: number -): Promise => { +): Promise => { 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)