From 288634c77400818ce1e330c5bd5ca4df16c4a774 Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Thu, 10 Sep 2026 03:02:39 +0800 Subject: [PATCH] feat: add password-only mailbox login and bound password reset --- CHANGELOG.md | 3 + CHANGELOG_EN.md | 3 + e2e/tests/api/address-credential.spec.ts | 52 +++++++++++++ e2e/tests/api/address-password.spec.ts | 44 +++++++++++ frontend/src/api/index.js | 34 ++++++--- .../components/AddressCredentialContent.vue | 8 +- .../src/components/AddressCredentialModal.vue | 5 +- frontend/src/components/AddressSelect.vue | 41 ++++------ frontend/src/i18n/message-registry.ts | 24 ++++++ frontend/src/router/index.js | 12 ++- frontend/src/store/index.js | 1 + frontend/src/utils/local-address-cache.ts | 26 +++++++ frontend/src/views/common/Login.vue | 5 +- frontend/src/views/index/AccountSettings.vue | 2 +- frontend/src/views/index/LocalAddress.vue | 62 +++++---------- frontend/src/views/user/AddressManagement.vue | 76 +++++++++++++++++-- vitepress-docs/docs/.vitepress/en.ts | 1 + vitepress-docs/docs/.vitepress/zh.ts | 1 + .../en/guide/feature/admin-user-management.md | 4 + .../docs/en/guide/feature/mail-api.md | 4 + .../guide/feature/mailbox-password-login.md | 48 ++++++++++++ .../docs/en/guide/feature/telegram.md | 4 + vitepress-docs/docs/en/guide/worker-vars.md | 1 + .../zh/guide/feature/admin-user-management.md | 4 + .../docs/zh/guide/feature/mail-api.md | 4 + .../guide/feature/mailbox-password-login.md | 48 ++++++++++++ .../docs/zh/guide/feature/telegram.md | 4 + vitepress-docs/docs/zh/guide/worker-vars.md | 1 + worker/src/address_auth.ts | 45 ++++++++++- worker/src/admin_api/address_api.ts | 10 +-- worker/src/commom_api.ts | 3 +- worker/src/common.ts | 7 +- worker/src/i18n/en.ts | 1 + worker/src/i18n/type.ts | 1 + worker/src/i18n/zh.ts | 1 + worker/src/mails_api/address_auth.ts | 11 +-- worker/src/mails_api/mails_crud.ts | 10 ++- worker/src/open_api/auth.ts | 5 +- worker/src/telegram_api/common.ts | 33 ++++++-- worker/src/telegram_api/miniapp.ts | 8 +- worker/src/types.d.ts | 14 +++- worker/src/user_api/address_password.ts | 29 +++++++ worker/src/user_api/bind_address.ts | 7 +- worker/src/user_api/index.ts | 2 + worker/src/utils.ts | 4 + worker/wrangler.toml.template | 3 + 46 files changed, 578 insertions(+), 138 deletions(-) create mode 100644 frontend/src/utils/local-address-cache.ts create mode 100644 vitepress-docs/docs/en/guide/feature/mailbox-password-login.md create mode 100644 vitepress-docs/docs/zh/guide/feature/mailbox-password-login.md create mode 100644 worker/src/user_api/address_password.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ff2197..8b69cac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码 - feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量 - feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置 - feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示 @@ -21,6 +22,7 @@ ### Bug Fixes +- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离 - fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码 - fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性 - fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题 @@ -35,6 +37,7 @@ ### Testing +- test: |邮箱登录| 覆盖密码登录 JWT 有效期与续期、非法 token 类型,以及用户重置绑定邮箱密码的权限检查 - test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复 - fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览 - fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 74321d60..b6cd27a6 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,6 +10,7 @@ ### Features +- feat: |Mailbox Login| Add a password-only switch that rejects legacy credentials, 30-day mailbox login JWTs renewed with less than 7 days remaining, and password reset for bound mailboxes - feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes - feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html` - feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation @@ -21,6 +22,7 @@ ### Bug Fixes +- fix: |Mailbox Login| Cache both login methods using backend settings without decoding JWTs in the frontend; use non-expiring Telegram binding tokens independently of web mailbox login JWTs - fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; move E2E test endpoints out of production code - fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script - fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading @@ -35,6 +37,7 @@ ### Testing +- test: |Mailbox Login| Cover password JWT lifetime and renewal, invalid token types, and authorization for bound mailbox password resets - test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads - fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer - fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address diff --git a/e2e/tests/api/address-credential.spec.ts b/e2e/tests/api/address-credential.spec.ts index 00bf7741..66a1adef 100644 --- a/e2e/tests/api/address-credential.spec.ts +++ b/e2e/tests/api/address-credential.spec.ts @@ -11,6 +11,58 @@ function signToken(payload: Record) { return `${header}.${body}.${signature}`; } +test('password login tokens renew below seven days; legacy credentials do not renew', async ({ request }) => { + const mailbox = await createTestAddress(request, 'password-renewal'); + const day = 24 * 60 * 60; + const now = Math.floor(Date.now() / 1000); + const identity = { address: mailbox.address, address_id: mailbox.address_id }; + try { + for (const remainingDays of [8, 6]) { + const token = signToken({ + ...identity, type: 'address_password_login', + iat: now - (30 - remainingDays) * day, exp: now + remainingDays * day, + }); + const response = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.ok()).toBe(true); + const settings = await response.json(); + expect(settings.type).toBe('address_password_login'); + if (remainingDays > 7) { + expect(settings.new_address_token).toBeNull(); + continue; + } + expect(settings.new_address_token).toEqual(expect.any(String)); + const renewed = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${settings.new_address_token}` }, + }); + expect(renewed.ok()).toBe(true); + const renewedSettings = await renewed.json(); + expect(renewedSettings).toMatchObject({ ...identity, type: 'address_password_login', new_address_token: null }); + expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * day); + } + const legacy = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${mailbox.jwt}` }, + }); + expect(legacy.ok()).toBe(true); + expect(await legacy.json()).toMatchObject({ ...identity, new_address_token: null }); + for (const claims of [ + { type: 'telegram_binding' }, + { type: 'unknown' }, + { type: 'address_password_login' }, + { type: 'address_password_login', iat: now - 30 * day, exp: now - 1 }, + { type: 'address_password_login', iat: now, exp: now + 31 * day }, + ]) { + const response = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${signToken({ ...identity, ...claims })}` }, + }); + expect(response.status(), JSON.stringify(claims)).toBe(401); + } + } finally { + await deleteAddress(request, mailbox.jwt); + } +}); + async function expectRejected(request: APIRequestContext, jwt: string) { for (const [method, path] of [ ['GET', '/api/settings'], diff --git a/e2e/tests/api/address-password.spec.ts b/e2e/tests/api/address-password.spec.ts index 83fb171f..727fbdd7 100644 --- a/e2e/tests/api/address-password.spec.ts +++ b/e2e/tests/api/address-password.spec.ts @@ -30,6 +30,10 @@ test.describe('Address Password Login', () => { headers: { Authorization: `Bearer ${loginBody.jwt}` }, }); expect(settingsRes.ok()).toBe(true); + const settings = await settingsRes.json(); + expect(settings.type).toBe('address_password_login'); + expect(settings.exp - settings.iat).toBe(30 * 24 * 60 * 60); + expect(settings.new_address_token).toBeNull(); } finally { await deleteAddress(request, jwt); } @@ -164,4 +168,44 @@ test.describe('Address Password Login', () => { await deleteAddress(request, jwt); } }); + + test('users can reset a mailbox password only while it is bound to them', async ({ request }) => { + const { jwt, address, address_id } = await createTestAddress(request, 'pwd-user-reset'); + const email = `pwd-reset-user-${Date.now()}@test.example.com`; + const password = hashPassword('password-reset-user'); + const newPassword = hashPassword('replacement-mailbox-password'); + try { + const enable = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { enable: true, enableMailVerify: false }, + }); + expect(enable.ok()).toBe(true); + const register = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password } }); + expect(register.ok()).toBe(true); + const login = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password } }); + expect(login.ok()).toBe(true); + const { jwt: userJwt } = await login.json(); + const headers = { 'x-user-token': userJwt }; + const reset = (value = newPassword) => request.post(`${WORKER_URL}/user_api/address/${address_id}/reset_password`, { + headers, data: { new_password: value }, + }); + expect((await reset()).status()).toBe(403); + const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { ...headers, Authorization: `Bearer ${jwt}` }, + }); + expect(bind.ok()).toBe(true); + expect((await reset('plaintext')).status()).toBe(400); + expect((await reset()).ok()).toBe(true); + const mailboxLogin = await request.post(`${WORKER_URL}/api/address_login`, { + data: { email: address, password: newPassword }, + }); + expect(mailboxLogin.ok()).toBe(true); + const unbind = await request.post(`${WORKER_URL}/user_api/unbind_address`, { + headers, data: { address_id }, + }); + expect(unbind.ok()).toBe(true); + expect((await reset()).status()).toBe(403); + } finally { + await deleteAddress(request, jwt); + } + }); }); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 48807a53..6d023536 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,5 +1,6 @@ import { useGlobalState } from '../store' import { h } from 'vue' +import { useLocalStorage } from '@vueuse/core' import axios from 'axios' import i18n from '../i18n' @@ -9,6 +10,7 @@ import { sanitizeHtml } from '../utils/sanitize-html' import { APP_CONFIG } from '../config' import { createUserAccessTokenInterceptor } from './user-access-token-interceptor' import { ErrorCode } from './error-codes' +import { updateLocalAddressCache } from '../utils/local-address-cache' const API_BASE = APP_CONFIG.API_BASE || ""; const { @@ -17,6 +19,8 @@ const { showAuth, adminAuth, showAdminAuth, userJwt } = useGlobalState(); +const localAddressCache = useLocalStorage('LocalAddressCache', []); + const instance = axios.create({ baseURL: API_BASE, timeout: 30000, @@ -55,7 +59,7 @@ const apiFetch = async (path, options = {}) => { if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader; const adminAuthHeader = safeHeaderValue(adminAuth.value); if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader; - const authorizationHeader = safeBearerHeader(jwt.value); + const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value); if (authorizationHeader) headers['Authorization'] = authorizationHeader; const initialResponse = await instance.request(path, { @@ -122,6 +126,7 @@ const getOpenSettings = async (message, notification) => { isS3Enabled: res["isS3Enabled"] || false, showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser, enableAddressPassword: res["enableAddressPassword"] || false, + addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true, enableAgentEmailInfo: res["enableAgentEmailInfo"] || false, enableRedeemCode: res["enableRedeemCode"] || false, redeemCodeUrl: res["redeemCodeUrl"] || "", @@ -154,18 +159,27 @@ const getOpenSettings = async (message, notification) => { } const getSettings = async () => { + let addressToken = jwt.value; try { - if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') { - return ""; + if (!safeHeaderValue(addressToken)) return; + const res = await apiFetch('/api/settings', { addressJwt: addressToken }); + if (jwt.value !== addressToken) return; + localAddressCache.value = updateLocalAddressCache(localAddressCache.value, addressToken, res); + settings.value = res; + const renewedAddressToken = res.new_address_token; + if (!renewedAddressToken) return; + try { + const renewedSettings = await apiFetch('/api/settings', { addressJwt: renewedAddressToken }); + if (jwt.value !== addressToken) return; + localAddressCache.value = updateLocalAddressCache(localAddressCache.value, renewedAddressToken, renewedSettings); + addressToken = renewedAddressToken; + jwt.value = renewedAddressToken; + settings.value = renewedSettings; + } catch (error) { + console.error('Failed to renew mailbox JWT', error); } - const res = await apiFetch("/api/settings");; - settings.value = { - address: res["address"], - auto_reply: res["auto_reply"], - send_balance: res["send_balance"], - }; } finally { - settings.value.fetched = true; + if (jwt.value === addressToken) settings.value.fetched = true; } } diff --git a/frontend/src/components/AddressCredentialContent.vue b/frontend/src/components/AddressCredentialContent.vue index 4770c933..600b46b0 100644 --- a/frontend/src/components/AddressCredentialContent.vue +++ b/frontend/src/components/AddressCredentialContent.vue @@ -98,10 +98,10 @@ const copyText = async (text) => {