mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-10 09:57:01 +08:00
feat: add password-only mailbox login and bound password reset
This commit is contained in:
@@ -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 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,58 @@ function signToken(payload: Record<string, unknown>) {
|
||||
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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+24
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,10 +98,10 @@ const copyText = async (text) => {
|
||||
<template>
|
||||
<div class="credential-content">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
{{ t('tip') }}
|
||||
{{ t(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
|
||||
</n-alert>
|
||||
<section class="credential-panel">
|
||||
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
|
||||
<h3 class="credential-title">{{ t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'addressCredential') }}</h3>
|
||||
<div class="credential-section">
|
||||
<div v-if="address" class="credential-field">
|
||||
<span class="credential-label">{{ t('currentAddress') }}</span>
|
||||
@@ -112,7 +112,7 @@ const copyText = async (text) => {
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<div v-if="!openSettings.addressPasswordLoginOnly" class="credential-field">
|
||||
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
||||
<div class="credential-copy-row">
|
||||
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
|
||||
@@ -128,7 +128,7 @@ const copyText = async (text) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-collapse accordion class="credential-collapse">
|
||||
<n-collapse v-if="!openSettings.addressPasswordLoginOnly" accordion class="credential-collapse">
|
||||
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
||||
|
||||
@@ -4,6 +4,9 @@ import { computed } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import AddressCredentialContent from './AddressCredentialContent.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -34,7 +37,7 @@ const modalShow = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
|
||||
<n-modal v-model:show="modalShow" preset="card" :title="t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'title')"
|
||||
style="width: min(760px, calc(100vw - 32px));">
|
||||
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
|
||||
</n-modal>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getCachedAddresses } from '../utils/local-address-cache'
|
||||
|
||||
const props = defineProps({
|
||||
showCopy: {
|
||||
@@ -28,6 +29,8 @@ const {
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('components.AddressSelect')
|
||||
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
|
||||
|
||||
const addressOptions = ref([])
|
||||
const addressValue = ref(null)
|
||||
@@ -45,21 +48,6 @@ const formatAddressLabel = (address) => {
|
||||
return address.replace('@' + domain, `@${domainLabel}`);
|
||||
}
|
||||
|
||||
const parseJwtAddress = (curJwt) => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return payload.address;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getOptionValue = (key, scope, payload, address) => {
|
||||
if (optionValueMap.has(key)) {
|
||||
const cached = optionValueMap.get(key)
|
||||
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
|
||||
}
|
||||
|
||||
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
const children = localAddressCache.value
|
||||
.map((curJwt) => {
|
||||
const address = parseJwtAddress(curJwt);
|
||||
if (!address) return null;
|
||||
const children = getCachedAddresses(localAddressCache.value)
|
||||
.map(({ token, address, type }, index) => {
|
||||
if (excludeAddresses.has(address)) return null;
|
||||
const label = formatAddressLabel(address);
|
||||
const key = `local:${curJwt}`;
|
||||
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
|
||||
if (settings.value.address && address === settings.value.address) {
|
||||
const isPasswordLogin = type === 'address_password_login';
|
||||
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||
const label = address
|
||||
? `${formatAddressLabel(address)} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||
: localAddressT('savedMailbox', { index: index + 1 });
|
||||
const key = `local:${token}`;
|
||||
const option = { label, value: getOptionValue(key, 'local', token, address), address };
|
||||
if (token === jwt.value) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
return option;
|
||||
@@ -207,7 +194,7 @@ onMounted(async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
|
||||
watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
||||
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -302,6 +302,10 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"components.AddressCredentialModal": {
|
||||
"passwordOnlyTip": {
|
||||
"en": "Save your mailbox password. Credential and login-link access are disabled. Bound mailboxes can also be opened from the user center.",
|
||||
"zh": "请保存邮箱密码。凭据及链接登录已禁用;已绑定邮箱仍可从用户中心进入。"
|
||||
},
|
||||
"addressCredential": {
|
||||
"en": "Address Credential",
|
||||
"zh": "地址凭证"
|
||||
@@ -1040,6 +1044,22 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.user.AddressManagement": {
|
||||
"resetPassword": {
|
||||
"en": "Reset Password",
|
||||
"zh": "重置密码"
|
||||
},
|
||||
"resetPasswordTip": {
|
||||
"en": "Set a new password for this bound mailbox without its old password.",
|
||||
"zh": "为已绑定邮箱设置新密码,无需提供原邮箱密码。"
|
||||
},
|
||||
"newPasswordRequired": {
|
||||
"en": "Enter a new password.",
|
||||
"zh": "请输入新密码。"
|
||||
},
|
||||
"unbindPasswordTip": {
|
||||
"en": "Save the mailbox password before unlinking so you can log in again.",
|
||||
"zh": "解绑前请保存邮箱密码,以便之后重新登录。"
|
||||
},
|
||||
"actions": {
|
||||
"en": "Actions",
|
||||
"zh": "操作"
|
||||
@@ -1928,6 +1948,10 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.index.LocalAddress": {
|
||||
"savedMailbox": {
|
||||
"en": "Saved mailbox {index}",
|
||||
"zh": "已保存邮箱 {index}"
|
||||
},
|
||||
"actions": {
|
||||
"en": "Actions",
|
||||
"zh": "操作"
|
||||
|
||||
@@ -4,6 +4,7 @@ import User from '../views/User.vue'
|
||||
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
||||
import i18n from '../i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
getBrowserLocales,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
resolveSupportedLocale,
|
||||
} from '../i18n/utils'
|
||||
|
||||
const { jwt, preferredLocale } = useGlobalState()
|
||||
const { jwt, preferredLocale, openSettings } = useGlobalState()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -55,7 +56,7 @@ const router = createRouter({
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const routeLocale = resolveSupportedLocale(to.path.split('/')[1])
|
||||
const resolvedLocale = routeLocale || DEFAULT_LOCALE
|
||||
i18n.global.locale.value = resolvedLocale
|
||||
@@ -69,7 +70,12 @@ router.beforeEach((to, from, next) => {
|
||||
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
|
||||
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
|
||||
if (typeof jwtQuery === 'string') {
|
||||
jwt.value = jwtQuery
|
||||
try {
|
||||
const config = openSettings.value.fetched ? openSettings.value : await api.fetch('/open_api/settings');
|
||||
if (!config.addressPasswordLoginOnly) jwt.value = jwtQuery;
|
||||
} catch {
|
||||
// Do not import a login link until the server policy is known.
|
||||
}
|
||||
}
|
||||
const query = { ...to.query }
|
||||
delete query.jwt
|
||||
|
||||
@@ -42,6 +42,7 @@ export const useGlobalState = createGlobalState(
|
||||
showGithubForUser: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
addressPasswordLoginOnly: false,
|
||||
enableAgentEmailInfo: false,
|
||||
enableRedeemCode: false,
|
||||
redeemCodeUrl: '',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type CachedAddress = {
|
||||
token: string;
|
||||
address?: string;
|
||||
type?: 'address_password_login' | null;
|
||||
};
|
||||
|
||||
export const getCachedAddresses = (cache: (string | CachedAddress)[]): CachedAddress[] => cache
|
||||
.map(entry => typeof entry === 'string' ? { token: entry } : entry)
|
||||
.filter(entry => typeof entry?.token === 'string' && entry.token);
|
||||
|
||||
export const updateLocalAddressCache = (
|
||||
cache: (string | CachedAddress)[], token: string,
|
||||
{ address, type }: { address: string; type?: 'address_password_login' },
|
||||
) => {
|
||||
const entries = getCachedAddresses(cache);
|
||||
const loginType = type ?? null;
|
||||
const existing = entries.find(entry => entry.token === token
|
||||
|| (entry.address === address && entry.type === loginType));
|
||||
const updated = { token, address, type: loginType };
|
||||
if (!existing) return [...entries, updated];
|
||||
return entries.flatMap(entry => {
|
||||
if (entry === existing) return [updated];
|
||||
if (entry.token === token || (entry.address === address && entry.type === loginType)) return [];
|
||||
return [entry];
|
||||
});
|
||||
};
|
||||
@@ -66,6 +66,7 @@ const initLoginMethod = () => {
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
if (openSettings.value.addressPasswordLoginOnly) loginMethod.value = 'password';
|
||||
if (loginMethod.value === 'password') {
|
||||
// Password login
|
||||
if (!loginAddress.value || !loginPassword.value) {
|
||||
@@ -246,6 +247,8 @@ const showNewAddressTab = computed(() => {
|
||||
return openSettings.value.enableUserCreateEmail;
|
||||
});
|
||||
|
||||
watch(() => openSettings.value.addressPasswordLoginOnly, initLoginMethod);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
||||
await api.getOpenSettings(message, notification);
|
||||
@@ -283,7 +286,7 @@ onMounted(async () => {
|
||||
v-model:value="loginCfToken" />
|
||||
|
||||
<div class="switch-login-button">
|
||||
<n-button v-if="openSettings?.enableAddressPassword"
|
||||
<n-button v-if="openSettings?.enableAddressPassword && !openSettings.addressPasswordLoginOnly"
|
||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||
type="info" quaternary size="tiny">
|
||||
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
||||
|
||||
@@ -93,7 +93,7 @@ const changePassword = async () => {
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded class="account-card">
|
||||
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||
<n-button v-if="!openSettings.addressPasswordLoginOnly" @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||
{{ t('showAddressCredential') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
|
||||
|
||||
@@ -3,63 +3,39 @@ import { ref, h, computed } from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { NPopconfirm, NButton } from 'naive-ui'
|
||||
import { getCachedAddresses } from '../../utils/local-address-cache'
|
||||
import type { CachedAddress } from '../../utils/local-address-cache'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
const { jwt, openSettings } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useScopedI18n('views.index.LocalAddress')
|
||||
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||
|
||||
const tabValue = ref('address')
|
||||
const localAddressCache = useLocalStorage("LocalAddressCache", []);
|
||||
const localAddressCache = useLocalStorage<(string | CachedAddress)[]>("LocalAddressCache", []);
|
||||
const data = computed(() => {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
return localAddressCache.value.map((curJwt: string) => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
|
||||
const isPasswordLogin = type === 'address_password_login';
|
||||
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||
return {
|
||||
valid: true,
|
||||
address: payload.address,
|
||||
jwt: curJwt
|
||||
address: address
|
||||
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||
: t('savedMailbox', { index: index + 1 }),
|
||||
jwt: token
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
address: `invalid jwt [${curJwt}]`,
|
||||
jwt: curJwt
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}).filter(Boolean)
|
||||
})
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
const bindAddress = () => {
|
||||
tabValue.value = 'address'
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
@@ -96,8 +72,8 @@ const columns = [
|
||||
if (jwt.value === row.jwt) {
|
||||
return;
|
||||
}
|
||||
localAddressCache.value = localAddressCache.value.filter(
|
||||
(curJwt: string) => curJwt !== row.jwt
|
||||
localAddressCache.value = getCachedAddresses(localAddressCache.value).filter(
|
||||
entry => entry.token !== row.jwt
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,12 +6,12 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../../utils'
|
||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
const { jwt, openSettings, loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -29,6 +29,43 @@ const targetUserEmail = ref('')
|
||||
const showAddressCredential = ref(false)
|
||||
const currentAddressCredential = ref('')
|
||||
const credentialAddress = ref('')
|
||||
const passwordResetAddress = ref(null)
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const isResettingPassword = ref(false)
|
||||
const { t: accountSettingsT } = useScopedI18n('views.index.AccountSettings')
|
||||
|
||||
const clearPasswordResetForm = () => {
|
||||
passwordResetAddress.value = null;
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
}
|
||||
|
||||
const resetBoundAddressPassword = async () => {
|
||||
if (!passwordResetAddress.value || isResettingPassword.value) return;
|
||||
if (!newPassword.value) {
|
||||
message.error(t('newPasswordRequired'));
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
message.error(accountSettingsT('passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
isResettingPassword.value = true;
|
||||
try {
|
||||
await api.fetch(`/user_api/address/${passwordResetAddress.value.id}/reset_password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: await hashPassword(newPassword.value) }),
|
||||
});
|
||||
message.success(accountSettingsT('passwordChanged'));
|
||||
clearPasswordResetForm();
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error');
|
||||
} finally {
|
||||
isResettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const showCredential = async (row) => {
|
||||
try {
|
||||
@@ -161,14 +198,26 @@ const columns = [
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
!openSettings.value.addressPasswordLoginOnly ? h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => showCredential(row)
|
||||
},
|
||||
{ default: () => credentialT('addressCredential') }
|
||||
),
|
||||
) : null,
|
||||
openSettings.value.enableAddressPassword ? h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordResetAddress.value = row;
|
||||
},
|
||||
},
|
||||
{ default: () => t('resetPassword') }
|
||||
) : null,
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => changeMailAddress(row.id)
|
||||
@@ -208,7 +257,7 @@ const columns = [
|
||||
},
|
||||
{ default: () => t('unbindAddress') }
|
||||
),
|
||||
default: () => t('unbindAddressTip')
|
||||
default: () => t(openSettings.value.addressPasswordLoginOnly ? 'unbindPasswordTip' : 'unbindAddressTip')
|
||||
}
|
||||
),
|
||||
])
|
||||
@@ -227,6 +276,23 @@ watch([page, pageSize], async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal :show="!!passwordResetAddress" @update:show="show => { if (!show && !isResettingPassword) clearPasswordResetForm() }"
|
||||
preset="dialog" :title="t('resetPassword')" :mask-closable="!isResettingPassword" :closable="!isResettingPassword">
|
||||
<p>{{ passwordResetAddress?.name }}</p>
|
||||
<p>{{ t('resetPasswordTip') }}</p>
|
||||
<n-form @submit.prevent="resetBoundAddressPassword">
|
||||
<n-form-item :label="accountSettingsT('newPassword')">
|
||||
<n-input v-model:value="newPassword" type="password" show-password-on="click" :disabled="isResettingPassword" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="accountSettingsT('confirmPassword')">
|
||||
<n-input v-model:value="confirmPassword" type="password" show-password-on="click"
|
||||
:disabled="isResettingPassword" @keyup.enter="resetBoundAddressPassword" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button type="warning" :loading="isResettingPassword" @click="resetBoundAddressPassword">{{ t('resetPassword') }}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||
:jwt="currentAddressCredential" />
|
||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||
|
||||
@@ -165,6 +165,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
text: 'Advanced Features',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Mailbox Password Login', link: 'feature/mailbox-password-login' },
|
||||
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
|
||||
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
|
||||
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
|
||||
|
||||
@@ -165,6 +165,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
text: '高级功能',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '邮箱密码登录', link: 'feature/mailbox-password-login' },
|
||||
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Admin User Management
|
||||
|
||||
::: info Mailbox password login
|
||||
See [mailbox password login](./mailbox-password-login) for password-only login, login JWT renewal and bound-mailbox password resets.
|
||||
:::
|
||||
|
||||
## User Management Page
|
||||
|
||||

|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Mail API
|
||||
|
||||
::: info Mailbox password login
|
||||
See [mailbox password login](./mailbox-password-login) for password-only login, login JWT renewal and bound-mailbox password resets.
|
||||
:::
|
||||
|
||||
## Viewing Emails via Mail API
|
||||
|
||||
This is a `python` example using the `requests` library to view emails.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Password-only mailbox login
|
||||
|
||||
```toml
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
ADDRESS_PASSWORD_LOGIN_ONLY = true
|
||||
```
|
||||
|
||||
The new switch defaults to `false` and only takes effect when mailbox passwords are enabled. It rejects legacy mailbox credentials, including direct API access, disables the credential login endpoint and `?jwt=` login links, and hides credential displays and automatic login links. Mailbox creation still displays the generated password. Users and administrators can still open mailboxes they are authorized to access and receive a new login JWT.
|
||||
|
||||
Existing mailboxes without passwords need a bound user or administrator to set one. Enabling passwords does not generate passwords for existing mailboxes. No database migration is required.
|
||||
|
||||
## Mailbox login JWT
|
||||
|
||||
Password login returns a `jwt` whose payload adds `type: "address_password_login"`, `iat`, and `exp` to the existing `address` and `address_id` fields. It lasts 30 days. Mailbox APIs continue using `Authorization: Bearer <jwt>`. Middleware validates the signature, type, expiration, and mailbox existence; business APIs keep using the same address fields.
|
||||
|
||||
`GET /api/settings` returns the current login information (`address`, `address_id`, and for password login, `type`, `iat`, and `exp`), sending balance, and `new_address_token`. When a valid login JWT has less than 7 days remaining, `new_address_token` contains a newly issued 30-day JWT; otherwise it is `null`.
|
||||
|
||||
The frontend follows the user login flow: `getSettings()` loads settings, validates the returned token with another settings request, then updates the current JWT and local mailbox cache. A renewal response cannot overwrite a different selected mailbox. Renewal runs when mailbox settings are loaded; ordinary API requests do not perform additional token checks or refreshes.
|
||||
|
||||
The local mailbox cache stores the token with the address and login type returned by `settings`; the frontend does not decode JWTs. Legacy credentials and password login JWTs are retained independently, updating the same address and type after successful login or renewal validation. Lists label the login method. Password-only login hides identified legacy credential entries without deleting them. Historical token-only entries initially appear as “Saved mailbox”; selecting and validating one fills in its information. The backend always rejects disabled credentials.
|
||||
|
||||
Expired JWTs cannot be renewed; log in again. Legacy credentials cannot obtain a new login JWT through `settings`. With the switch disabled, existing credentials retain their previous behavior, while password login still returns the new JWT format.
|
||||
|
||||
External clients keep the same APIs and headers. Clients using a new login JWT need to accept and save `settings.new_address_token`. Enabling the switch also rejects legacy credentials used directly against mailbox APIs by SMTP/IMAP, Agent, and other clients.
|
||||
|
||||
## Telegram bindings
|
||||
|
||||
After mailbox creation or binding, Telegram stores a non-expiring token with `type: "telegram_binding"` in KV. This token is internal to Telegram and is rejected by mailbox APIs. The Bot and Mini App authenticate the Telegram user before reading that user's bindings and checking that the mailbox still exists.
|
||||
|
||||
When the Mini App opens a mailbox, it issues a mailbox JWT under the current login policy. With password-only login enabled, that JWT lasts 30 days and follows the web renewal flow above. The KV binding does not need to rotate with the web JWT.
|
||||
|
||||
Existing KV bindings remain compatible: only internal Telegram binding verification ignores token expiration while validating the signature and mailbox identity. Tokens submitted to create a new binding still follow mailbox API validation, so disabled legacy credentials and expired JWTs cannot create new bindings. Unlinking or deleting a mailbox removes access through that Telegram binding.
|
||||
|
||||
## Reset a bound mailbox password
|
||||
|
||||
The user center mailbox list offers password reset without the previous mailbox password. This only requires `ENABLE_ADDRESS_PASSWORD`, independently of the password-only switch.
|
||||
|
||||
```http
|
||||
POST /user_api/address/:address_id/reset_password
|
||||
x-user-token: <user JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{"new_password":"<64-character lowercase SHA-256 hex digest of the new password>"}
|
||||
```
|
||||
|
||||
The same update statement checks that the user exists and currently owns the binding, and updates only the existing `password` and `updated_at` fields. Success returns `{"success":true}`. Missing user authentication returns 401; a mailbox not bound to the user or disabled passwords returns 403; invalid input returns 400.
|
||||
|
||||
Resetting a password does not revoke issued login JWTs; valid login JWTs can still renew. This implementation adds no session table or revocation state.
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Try it here: [@cf_temp_mail_bot](https://t.me/cf_temp_mail_bot)
|
||||
|
||||
::: info Mailbox login and bindings
|
||||
Telegram uses non-expiring internal binding tokens. Web mailbox login JWTs are issued and renewed separately. See [mailbox password login](./mailbox-password-login#telegram-bindings).
|
||||
:::
|
||||
|
||||
::: warning Note
|
||||
The default `worker.dev` domain certificate for worker is not supported by Telegram. Please use a custom domain when configuring Telegram Bot.
|
||||
:::
|
||||
|
||||
@@ -50,6 +50,7 @@ When `ADMIN_API_IP_WHITELIST` is unset or empty, source IPs are not restricted.
|
||||
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
|
||||
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `1` |
|
||||
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
|
||||
| `ADDRESS_PASSWORD_LOGIN_ONLY` | Text/JSON | Default `false`; only effective with mailbox passwords enabled. Rejects legacy credentials for login and API access, using renewable mailbox login JWTs. See [mailbox password login](./feature/mailbox-password-login). | `true` |
|
||||
| `ENABLE_AGENT_EMAIL_INFO` | Text/JSON | Whether to show AI Agent access info in the frontend "Address Credentials & Connection Methods" dialog (Address JWT, parsed-mail APIs, skill link) | `true` |
|
||||
| `SMTP_IMAP_PROXY_CONFIG` | JSON | Show SMTP/IMAP proxy connection info in the frontend "Address Credentials & Connection Methods" dialog; display-only, does not start the proxy service, which must be deployed separately | See example below |
|
||||
| `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` |
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Admin 用户相关
|
||||
|
||||
::: info 邮箱密码登录
|
||||
仅密码登录、登录 JWT 自动续期及绑定邮箱密码重置见[邮箱密码登录](./mailbox-password-login)。
|
||||
:::
|
||||
|
||||
## 用户管理页面
|
||||
|
||||

|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 查看邮件 API
|
||||
|
||||
::: info 邮箱密码登录
|
||||
仅密码登录、登录 JWT 自动续期及绑定邮箱密码重置见[邮箱密码登录](./mailbox-password-login)。
|
||||
:::
|
||||
|
||||
## 通过 邮件 API 查看邮件
|
||||
|
||||
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 仅允许邮箱密码登录
|
||||
|
||||
```toml
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
ADDRESS_PASSWORD_LOGIN_ONLY = true
|
||||
```
|
||||
|
||||
新开关默认 `false`,只有启用邮箱密码时才生效。开启后,后端拒绝旧邮箱凭据,包括直接访问邮箱 API;关闭凭据登录接口和 `?jwt=` 登录入口,前端隐藏凭据及自动登录链接。创建邮箱后仍显示生成的邮箱密码,用户中心和管理员仍可打开有权访问的邮箱,并获得新登录 JWT。
|
||||
|
||||
历史邮箱如果没有密码,需要由绑定用户或管理员设置;启用密码功能不会自动为历史邮箱生成密码。不需要数据库迁移。
|
||||
|
||||
## 邮箱登录 JWT
|
||||
|
||||
密码登录返回的 `jwt` 使用新 payload,在原有 `address`、`address_id` 上增加 `type: "address_password_login"`、`iat` 和 `exp`。有效期为 30 天,邮箱 API 仍使用 `Authorization: Bearer <jwt>`,中间件统一校验签名、类型、有效期和邮箱是否存在,业务 API 使用原有的地址字段。
|
||||
|
||||
`GET /api/settings` 返回当前登录信息(`address`、`address_id`,密码登录还包含 `type`、`iat`、`exp`)、发信余额及 `new_address_token`。有效登录 JWT 剩余不足 7 天时,`new_address_token` 为新签发的 30 天 JWT;否则为 `null`。
|
||||
|
||||
前端与用户登录采用相同流程:`getSettings()` 获取设置,使用返回的新 JWT 再次请求 `settings` 校验,成功后更新当前 JWT 和本地邮箱缓存。续期响应不会覆盖已经切换的邮箱。续期只在加载邮箱设置时处理,普通 API 请求不额外检查或刷新 JWT。
|
||||
|
||||
本地邮箱缓存保存 token 及 `settings` 返回的邮箱名称和登录类型,前端不解码 JWT。旧凭据与密码登录 JWT 独立保留,同邮箱、同类型在登录或续期校验成功后更新。列表标明登录方式,启用“只允许密码登录”后隐藏已识别的旧凭据入口,但保留缓存。历史缓存只有 token 时先显示“已保存邮箱”,选中且验证成功后补全信息;后端始终拒绝已禁用的凭据。
|
||||
|
||||
已过期 JWT 不能续期,需要重新登录;旧凭据不能通过 `settings` 换取新登录 JWT。开关关闭时,原有凭据仍按原逻辑使用,密码登录仍返回新格式 JWT。
|
||||
|
||||
外部客户端继续使用相同的 API 和请求头;使用新登录 JWT 时需接收并保存 `settings.new_address_token`。启用开关后,SMTP/IMAP、Agent 等客户端直接使用旧凭据访问邮箱 API 也会被拒绝。
|
||||
|
||||
## Telegram 绑定
|
||||
|
||||
Telegram 在创建或绑定邮箱后,在 KV 中保存 `type: "telegram_binding"` 的不过期 token。此 token 仅供 Telegram 内部使用,邮箱 API 不接受它。Bot 和 Mini App 先验证 Telegram 身份,再读取对应用户的绑定,并检查邮箱仍然存在。
|
||||
|
||||
Mini App 打开邮箱时,根据当前登录策略签发邮箱 JWT;启用仅密码登录后,该 JWT 有效期为 30 天,由网页沿用上述流程续期。KV 中的绑定不需要跟随网页 JWT 续期。
|
||||
|
||||
已有 KV 绑定兼容旧格式,仅在 Telegram 内部验证签名和邮箱身份时不检查 token 的过期时间。外部提交的绑定请求仍按邮箱 API 规则校验 JWT,不能使用已禁用的旧凭据或过期 JWT 新增绑定。解绑或删除邮箱后,原 Telegram 绑定不再授予访问权限。
|
||||
|
||||
## 用户重置绑定邮箱密码
|
||||
|
||||
用户中心邮箱列表提供“重置密码”,不需要邮箱原密码。此功能仅依赖 `ENABLE_ADDRESS_PASSWORD`,不要求开启仅密码登录。
|
||||
|
||||
```http
|
||||
POST /user_api/address/:address_id/reset_password
|
||||
x-user-token: <用户JWT>
|
||||
Content-Type: application/json
|
||||
|
||||
{"new_password":"<新密码的64位小写SHA-256十六进制值>"}
|
||||
```
|
||||
|
||||
后端在同一条更新语句中确认用户存在及当前绑定关系,只更新现有 `password`、`updated_at` 字段。成功返回 `{"success":true}`;未登录返回 401,邮箱不属于当前用户或密码功能关闭返回 403,输入格式错误返回 400。
|
||||
|
||||
密码重置只修改密码,不撤销已经签发的登录 JWT;有效登录 JWT 仍可续期。本方案不新增会话表或撤销状态。
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
试用地址:[@cf_temp_mail_bot](https://t.me/cf_temp_mail_bot)
|
||||
|
||||
::: info 邮箱登录与绑定
|
||||
Telegram 内部绑定使用不过期 token,网页邮箱登录 JWT 单独签发和续期。详见[邮箱密码登录](./mailbox-password-login#telegram-绑定)。
|
||||
:::
|
||||
|
||||
::: warning 注意
|
||||
worker 默认的 `worker.dev` 域名的证书是不被 telegram 支持的,配置 Telegram Bot 请使用自定义域名
|
||||
:::
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` |
|
||||
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
|
||||
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
|
||||
| `ADDRESS_PASSWORD_LOGIN_ONLY` | 文本/JSON | 默认 `false`,仅在启用邮箱密码时生效;禁用旧凭据登录及 API 访问,使用可自动续期的邮箱登录 JWT。见[邮箱密码登录](./feature/mailbox-password-login) | `true` |
|
||||
| `ENABLE_AGENT_EMAIL_INFO` | 文本/JSON | 是否在前端“地址凭证与连接方式”弹窗中展示 AI Agent 接入信息(Address JWT、parsed-mail API、skill 链接) | `true` |
|
||||
| `SMTP_IMAP_PROXY_CONFIG` | JSON | 在前端“地址凭证与连接方式”弹窗中展示 SMTP/IMAP 代理连接信息;仅用于展示给用户,不会启动代理服务,代理服务仍需单独部署 | 见下方示例 |
|
||||
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
|
||||
|
||||
@@ -3,11 +3,34 @@ import { jwt } from 'hono/jwt';
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
|
||||
import i18n from './i18n';
|
||||
import { isAddressPasswordLoginOnly } from './utils';
|
||||
|
||||
export const validateAddressPayload = async (
|
||||
const ADDRESS_PASSWORD_LOGIN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
export const ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
export const createAddressPasswordLoginToken = (
|
||||
c: Context<HonoCustomType>, address: string, addressId: number,
|
||||
) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: AddressPasswordLoginPayload = {
|
||||
address, address_id: addressId, type: 'address_password_login',
|
||||
iat: now, exp: now + ADDRESS_PASSWORD_LOGIN_TTL_SECONDS,
|
||||
};
|
||||
return Jwt.sign(payload, c.env.JWT_SECRET, 'HS256');
|
||||
};
|
||||
|
||||
export const createAddressToken = (
|
||||
c: Context<HonoCustomType>, address: string, addressId: number,
|
||||
) => {
|
||||
if (isAddressPasswordLoginOnly(c)) return createAddressPasswordLoginToken(c, address, addressId);
|
||||
const payload: AddressCredentialPayload = { address, address_id: addressId };
|
||||
return Jwt.sign(payload, c.env.JWT_SECRET, 'HS256');
|
||||
};
|
||||
|
||||
export const validateAddressIdentity = async (
|
||||
c: Context<HonoCustomType>,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<JwtPayload | null> => {
|
||||
): Promise<AddressCredentialPayload | null> => {
|
||||
const { address, address_id } = payload;
|
||||
if (typeof address !== 'string' || !address) return null;
|
||||
if (typeof address_id !== 'number'
|
||||
@@ -21,6 +44,24 @@ export const validateAddressPayload = async (
|
||||
return exists ? { address, address_id: addressId } : null;
|
||||
};
|
||||
|
||||
export const validateAddressPayload = async (
|
||||
c: Context<HonoCustomType>,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<JwtPayload | null> => {
|
||||
if (payload.type !== undefined && payload.type !== 'address_password_login') return null;
|
||||
if (isAddressPasswordLoginOnly(c) && payload.type !== 'address_password_login') return null;
|
||||
const identity = await validateAddressIdentity(c, payload);
|
||||
if (!identity) return null;
|
||||
if (payload.type !== 'address_password_login') return identity;
|
||||
const { iat, exp } = payload;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (typeof iat !== 'number' || !Number.isSafeInteger(iat) || iat > now
|
||||
|| typeof exp !== 'number' || !Number.isSafeInteger(exp) || exp <= now
|
||||
|| exp <= iat || exp - iat > ADDRESS_PASSWORD_LOGIN_TTL_SECONDS
|
||||
) return null;
|
||||
return { ...identity, type: 'address_password_login', iat, exp };
|
||||
};
|
||||
|
||||
export const verifyAddressToken = async (
|
||||
c: Context<HonoCustomType>,
|
||||
token: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { createAddressToken } from '../address_auth';
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getBooleanValue } from '../utils'
|
||||
@@ -134,11 +134,9 @@ const showPassword = async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
).bind(id).first<string>("name");
|
||||
if (!name) return c.text(i18n.getMessagesbyContext(c).AddressNotFoundMsg, 404);
|
||||
const jwt = await createAddressToken(c, name, Number(id));
|
||||
return c.json({ jwt });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import utils from './utils';
|
||||
import utils, { isAddressPasswordLoginOnly } from './utils';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { isS3Enabled } from './mails_api/s3_attachment';
|
||||
import { isAnySendMailEnabled } from './common';
|
||||
@@ -22,6 +22,7 @@ api.get('/open_api/settings', async (c) => {
|
||||
const imapProxyConfig = smtpImapProxyConfig.imap || {};
|
||||
|
||||
return c.json({
|
||||
"addressPasswordLoginOnly": isAddressPasswordLoginOnly(c),
|
||||
"title": c.env.TITLE,
|
||||
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
|
||||
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { createAddressToken } from './address_auth';
|
||||
import { WorkerMailerOptions } from 'worker-mailer';
|
||||
|
||||
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, isDomainOrSubdomain, normalizeDomains, trimLower } from './utils';
|
||||
@@ -452,10 +452,7 @@ export const newAddress = async (
|
||||
const generatedPassword = await generatePasswordForAddress(c, address);
|
||||
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: address,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
const jwt = await createAddressToken(c, address, address_id);
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: address,
|
||||
|
||||
@@ -49,6 +49,7 @@ const messages: LocaleMessages = {
|
||||
NewPasswordRequiredMsg: "New password is required",
|
||||
InvalidAddressTokenMsg: "Invalid address token",
|
||||
FailedUpdatePasswordMsg: "Failed to update password",
|
||||
CredentialLoginDisabledMsg: "Mailbox password login is required; credential login is disabled",
|
||||
PasswordLoginDisabledMsg: "Password login is disabled",
|
||||
EmailPasswordRequiredMsg: "Email and password are required",
|
||||
AddressNotFoundMsg: "Address not found",
|
||||
|
||||
@@ -47,6 +47,7 @@ export type LocaleMessages = {
|
||||
NewPasswordRequiredMsg: string
|
||||
InvalidAddressTokenMsg: string
|
||||
FailedUpdatePasswordMsg: string
|
||||
CredentialLoginDisabledMsg: string
|
||||
PasswordLoginDisabledMsg: string
|
||||
EmailPasswordRequiredMsg: string
|
||||
AddressNotFoundMsg: string
|
||||
|
||||
@@ -49,6 +49,7 @@ const messages: LocaleMessages = {
|
||||
NewPasswordRequiredMsg: "新密码不能为空",
|
||||
InvalidAddressTokenMsg: "无效的地址令牌",
|
||||
FailedUpdatePasswordMsg: "更新密码失败",
|
||||
CredentialLoginDisabledMsg: "仅允许邮箱密码登录,凭据登录已禁用",
|
||||
PasswordLoginDisabledMsg: "密码登录已禁用",
|
||||
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
|
||||
AddressNotFoundMsg: "邮箱地址不存在",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from 'hono';
|
||||
import i18n from '../i18n';
|
||||
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
import utils, { getBooleanValue, checkCfTurnstile } from '../utils';
|
||||
import { createAddressPasswordLoginToken } from '../address_auth';
|
||||
|
||||
export default {
|
||||
// 修改地址密码
|
||||
@@ -61,7 +61,7 @@ export default {
|
||||
// 查找地址
|
||||
const address = await c.env.DB.prepare(
|
||||
`SELECT * FROM address WHERE name = ?`
|
||||
).bind(email).first();
|
||||
).bind(email).first<{ id: number; name: string; password: string | null }>();
|
||||
|
||||
if (!address) {
|
||||
return c.text(msgs.AddressNotFoundMsg, 404);
|
||||
@@ -73,10 +73,7 @@ export default {
|
||||
}
|
||||
|
||||
// 创建JWT
|
||||
const jwt = await Jwt.sign({
|
||||
address: address.name,
|
||||
address_id: address.id
|
||||
}, c.env.JWT_SECRET, "HS256");
|
||||
const jwt = await createAddressPasswordLoginToken(c, address.name, address.id);
|
||||
|
||||
return c.json({
|
||||
jwt: jwt,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getBooleanValue } from '../utils';
|
||||
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
import { getSendBalanceState } from './send_balance';
|
||||
import { createAddressPasswordLoginToken, ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS } from '../address_auth';
|
||||
|
||||
const listMails = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
@@ -62,14 +63,19 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||
};
|
||||
|
||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const payload = c.get("jwtPayload");
|
||||
const { address } = payload;
|
||||
const renewedAddressToken = payload.type === 'address_password_login'
|
||||
&& payload.exp < Math.floor(Date.now() / 1000) + ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS
|
||||
? await createAddressPasswordLoginToken(c, address, payload.address_id) : null;
|
||||
|
||||
updateAddressUpdatedAt(c, address);
|
||||
|
||||
const { balance } = await getSendBalanceState(c, address);
|
||||
return c.json({
|
||||
address: address,
|
||||
...payload,
|
||||
send_balance: balance || 0,
|
||||
new_address_token: renewedAddressToken,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { verifyAddressToken } from '../address_auth';
|
||||
|
||||
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
|
||||
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword, isAddressPasswordLoginOnly } from '../utils';
|
||||
import i18n from '../i18n';
|
||||
import { ErrorCode } from '../error_codes';
|
||||
|
||||
@@ -44,8 +44,9 @@ api.post('/open_api/admin_login', async (c) => {
|
||||
})
|
||||
|
||||
api.post('/open_api/credential_login', async (c) => {
|
||||
const { credential, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (isAddressPasswordLoginOnly(c)) return c.text(msgs.CredentialLoginDisabledMsg, 403);
|
||||
const { credential, cf_token } = await c.req.json();
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Context } from "hono";
|
||||
import { Jwt } from "hono/utils/jwt";
|
||||
import { validateAddressPayload, verifyAddressToken } from '../address_auth';
|
||||
import { validateAddressIdentity, verifyAddressToken } from '../address_auth';
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
|
||||
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
|
||||
import { LocaleMessages } from "../i18n/type";
|
||||
import i18n from '../i18n';
|
||||
|
||||
const createTelegramBindingToken = (c: Context<HonoCustomType>, address: string, addressId: number) =>
|
||||
Jwt.sign({ type: 'telegram_binding', address, address_id: addressId }, c.env.JWT_SECRET, 'HS256');
|
||||
|
||||
// Only for tokens read from the authenticated Telegram user's stored bindings.
|
||||
export const verifyTelegramBindingToken = async (c: Context<HonoCustomType>, token: string) => {
|
||||
const payload = await Jwt.verify(token, c.env.JWT_SECRET, { alg: 'HS256', exp: false });
|
||||
if (payload.type !== undefined && payload.type !== 'telegram_binding'
|
||||
&& payload.type !== 'address_password_login') {
|
||||
throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
|
||||
}
|
||||
const identity = await validateAddressIdentity(c, payload);
|
||||
if (!identity) throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
|
||||
return identity;
|
||||
};
|
||||
|
||||
export const tgUserNewAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, address: string,
|
||||
msgs: LocaleMessages,
|
||||
@@ -48,7 +63,8 @@ export const tgUserNewAddress = async (
|
||||
sourceMeta: `tg:${userId}`
|
||||
});
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
|
||||
const bindingToken = await createTelegramBindingToken(c, res.address, res.address_id);
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, bindingToken]));
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
|
||||
return res;
|
||||
}
|
||||
@@ -65,7 +81,7 @@ export const jwtListToAddressData = async (
|
||||
const invalidJwtList = [] as string[];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address, address_id } = await verifyAddressToken(c, jwt);
|
||||
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
|
||||
addressList.push(address as string);
|
||||
addressIdMap[address as string] = address_id as number;
|
||||
} catch (e) {
|
||||
@@ -81,7 +97,7 @@ export const bindTelegramAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, jwt: string,
|
||||
msgs: LocaleMessages
|
||||
): Promise<string> => {
|
||||
const { address } = await verifyAddressToken(c, jwt);
|
||||
const { address, address_id } = await verifyAddressToken(c, jwt);
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
|
||||
if (address as string in addressIdMap) {
|
||||
@@ -91,7 +107,8 @@ export const bindTelegramAddress = async (
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
throw Error(msgs.TgMaxAddressReachedCleanMsg);
|
||||
}
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
|
||||
const bindingToken = await createTelegramBindingToken(c, address, address_id);
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, bindingToken]));
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
|
||||
return address as string;
|
||||
@@ -101,7 +118,7 @@ const getTelegramBindings = async (c: Context<HonoCustomType>, userId: string) =
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
return Promise.all(jwtList.map(async (jwt) => {
|
||||
try {
|
||||
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256") };
|
||||
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, { alg: 'HS256', exp: false }) };
|
||||
} catch (e) {
|
||||
console.log(`解绑失败: ${(e as Error).message}`);
|
||||
return { jwt, payload: null };
|
||||
@@ -125,10 +142,10 @@ export const unbindTelegramAddress = async (
|
||||
): Promise<boolean> => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const bindings = await getTelegramBindings(c, userId);
|
||||
for (const { payload } of bindings) {
|
||||
for (const { jwt, payload } of bindings) {
|
||||
if (payload?.address !== address) continue;
|
||||
try {
|
||||
if (!await validateAddressPayload(c, payload)) continue;
|
||||
await verifyTelegramBindingToken(c, jwt);
|
||||
} catch (e) {
|
||||
console.log(`Failed to validate Telegram binding: ${(e as Error).message}`);
|
||||
continue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
import { verifyAddressToken } from '../address_auth';
|
||||
import { createAddressToken } from '../address_auth';
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
|
||||
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, verifyTelegramBindingToken } from "./common";
|
||||
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
||||
import { resolveRawEmailRow } from "../gzip";
|
||||
import { TelegramSettings } from "./settings";
|
||||
@@ -69,8 +69,8 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
|
||||
const res = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address } = await verifyAddressToken(c, jwt);
|
||||
res.push({ address, jwt });
|
||||
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
|
||||
res.push({ address, jwt: await createAddressToken(c, address, address_id) });
|
||||
} catch (e) {
|
||||
console.error(`failed to verify jwt with error: ${e}`)
|
||||
continue;
|
||||
|
||||
Vendored
+13
-1
@@ -59,6 +59,7 @@ type Bindings = {
|
||||
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
|
||||
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
|
||||
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
|
||||
ADDRESS_PASSWORD_LOGIN_ONLY: string | boolean | undefined
|
||||
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
|
||||
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
|
||||
ENABLE_REDEEM_CODE: string | boolean | undefined
|
||||
@@ -125,11 +126,22 @@ type Bindings = {
|
||||
CLEANUP_BATCH_SIZE: string | number | undefined
|
||||
}
|
||||
|
||||
type JwtPayload = {
|
||||
type AddressCredentialPayload = {
|
||||
address: string
|
||||
address_id: number
|
||||
type?: never
|
||||
}
|
||||
|
||||
type AddressPasswordLoginPayload = {
|
||||
address: string
|
||||
address_id: number
|
||||
type: 'address_password_login'
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
type JwtPayload = AddressCredentialPayload | AddressPasswordLoginPayload
|
||||
|
||||
type UserPayload = {
|
||||
user_email: string
|
||||
user_id: number
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'hono';
|
||||
import { getBooleanValue } from '../utils';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export const resetBoundAddressPassword = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text(msgs.PasswordChangeDisabledMsg, 403);
|
||||
}
|
||||
const addressId = Number(c.req.param('address_id'));
|
||||
const userId = c.get('userPayload')?.user_id;
|
||||
if (!Number.isSafeInteger(addressId) || addressId <= 0 || !userId) {
|
||||
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400);
|
||||
}
|
||||
const body = await c.req.json<{ new_password?: unknown }>().catch(() => null);
|
||||
if (typeof body?.new_password !== 'string' || !/^[a-f0-9]{64}$/.test(body.new_password)) {
|
||||
return c.text(msgs.InvalidInputMsg, 400);
|
||||
}
|
||||
const result = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now')
|
||||
WHERE id = ? AND EXISTS (
|
||||
SELECT 1 FROM users_address ua JOIN users u ON u.id = ua.user_id
|
||||
WHERE ua.address_id = address.id AND ua.user_id = ?
|
||||
)`
|
||||
).bind(body.new_password, addressId, userId).run();
|
||||
if (!result.success) return c.text(msgs.FailedUpdatePasswordMsg, 500);
|
||||
if (result.meta.changes !== 1) return c.text(msgs.AddressNotBindedMsg, 403);
|
||||
return c.json({ success: true });
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { createAddressToken } from '../address_auth';
|
||||
|
||||
import { isAddressCountLimitReached } from "../utils"
|
||||
import { unbindTelegramByAddress } from '../telegram_api/common';
|
||||
@@ -178,10 +178,7 @@ const UserBindAddressModule = {
|
||||
if (!name) {
|
||||
return c.text(msgs.AddressNotBindedMsg, 400)
|
||||
}
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
const jwt = await createAddressToken(c, name, Number(address_id));
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import passkey from './passkey';
|
||||
import oauth2 from './oauth2';
|
||||
import user_mail_api from './user_mail_api';
|
||||
import user_send_mail_api from './user_send_mail_api';
|
||||
import { resetBoundAddressPassword } from './address_password';
|
||||
|
||||
export const api = new Hono<HonoCustomType>();
|
||||
|
||||
@@ -38,6 +39,7 @@ api.post('/user_api/oauth2/callback', oauth2.oauth2Login);
|
||||
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
|
||||
api.post('/user_api/bind_address', bind_address.bind);
|
||||
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
|
||||
api.post('/user_api/address/:address_id/reset_password', resetBoundAddressPassword);
|
||||
api.post('/user_api/unbind_address', bind_address.unbind);
|
||||
api.post('/user_api/transfer_address', bind_address.transferAddress);
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Context } from "hono";
|
||||
import { UserSettings, RoleAddressConfig } from "./models";
|
||||
import { CONSTANTS } from "./constants";
|
||||
|
||||
export const isAddressPasswordLoginOnly = (c: Context<HonoCustomType>): boolean =>
|
||||
getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)
|
||||
&& getBooleanValue(c.env.ADDRESS_PASSWORD_LOGIN_ONLY);
|
||||
|
||||
export const getJsonObjectValue = <T = any>(
|
||||
value: string | any
|
||||
): T | null => {
|
||||
|
||||
@@ -93,6 +93,9 @@ ENABLE_AUTO_REPLY = false
|
||||
# ENABLE_WEBHOOK = true
|
||||
# Enable address password feature, if set true, will generate password for new address and support password login and change
|
||||
# ENABLE_ADDRESS_PASSWORD = false
|
||||
# Only allow password login to mailboxes (requires ENABLE_ADDRESS_PASSWORD).
|
||||
# Rejects legacy mailbox credentials, including direct API access.
|
||||
# ADDRESS_PASSWORD_LOGIN_ONLY = false
|
||||
# Show AI Agent mailbox connection info in the address credential modal
|
||||
# ENABLE_AGENT_EMAIL_INFO = true
|
||||
# Show SMTP/IMAP client connection info in the address credential modal
|
||||
|
||||
Reference in New Issue
Block a user