diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef48432..d57364db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容 +- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767) ### Bug Fixes diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 00e8e384..f22fdfeb 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -11,6 +11,7 @@ ### Features - feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching +- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767) ### Bug Fixes diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 8076222f..b0805260 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -16,6 +16,7 @@ ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true +ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true E2E_TEST_MODE = true SMTP_CONFIG = """ diff --git a/e2e/tests/api/login-endpoints.spec.ts b/e2e/tests/api/login-endpoints.spec.ts new file mode 100644 index 00000000..1d4a2450 --- /dev/null +++ b/e2e/tests/api/login-endpoints.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; +import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers'; +import * as crypto from 'crypto'; + +/** + * SHA-256 hash matching frontend hashPassword utility. + */ +function hashPassword(password: string): string { + return crypto.createHash('sha256').update(password).digest('hex'); +} + +test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => { + + test('settings returns enableGlobalTurnstileCheck as false', async ({ request }) => { + const res = await request.get(`${WORKER_URL}/open_api/settings`); + expect(res.ok()).toBe(true); + const settings = await res.json(); + expect(settings.enableGlobalTurnstileCheck).toBe(false); + }); + + test.describe('/open_api/site_login', () => { + test('returns 401 when no PASSWORDS configured', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/open_api/site_login`, { + data: { + password: hashPassword('any-pass'), + cf_token: '' + } + }); + expect(res.status()).toBe(401); + }); + }); + + test.describe('/open_api/admin_login', () => { + test('correct hashed password succeeds', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/open_api/admin_login`, { + data: { + password: hashPassword('e2e-admin-pass'), + cf_token: '' + } + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.success).toBe(true); + }); + + test('wrong password returns 401', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/open_api/admin_login`, { + data: { + password: hashPassword('wrong-admin'), + cf_token: '' + } + }); + expect(res.status()).toBe(401); + }); + + test('empty password returns 401', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/open_api/admin_login`, { + data: { + password: '', + cf_token: '' + } + }); + expect(res.status()).toBe(401); + }); + }); + + test.describe('/open_api/credential_login', () => { + test('valid JWT credential succeeds', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'cred-login'); + try { + const res = await request.post(`${WORKER_URL}/open_api/credential_login`, { + data: { + credential: jwt, + cf_token: '' + } + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.success).toBe(true); + } finally { + await deleteAddress(request, jwt); + } + }); + + test('invalid JWT returns 401', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/open_api/credential_login`, { + data: { + credential: 'invalid.jwt.token', + cf_token: '' + } + }); + expect(res.status()).toBe(401); + }); + + test('empty credential returns 401', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/open_api/credential_login`, { + data: { + credential: '', + cf_token: '' + } + }); + expect(res.status()).toBe(401); + }); + }); + + test.describe('/api/address_login with cf_token', () => { + test('address login with empty cf_token works when turnstile disabled', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'addr-cf'); + try { + // Set a password + await request.post(`${WORKER_URL}/api/address_change_password`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { new_password: 'addr-pass-123' }, + }); + + // Login with cf_token field present but empty + const loginRes = await request.post(`${WORKER_URL}/api/address_login`, { + data: { + email: address, + password: 'addr-pass-123', + cf_token: '' + }, + }); + expect(loginRes.ok()).toBe(true); + const body = await loginRes.json(); + expect(body.jwt).toBeTruthy(); + } finally { + await deleteAddress(request, jwt); + } + }); + }); +}); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e1d8e0df..8554237a 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -93,6 +93,7 @@ const getOpenSettings = async (message, notification) => { isS3Enabled: res["isS3Enabled"] || false, enableAddressPassword: res["enableAddressPassword"] || false, statusUrl: res["statusUrl"] || "", + enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false, }); if (openSettings.value.needAuth) { showAuth.value = true; diff --git a/frontend/src/components/Turnstile.vue b/frontend/src/components/Turnstile.vue index 89df4936..e22a7cb6 100644 --- a/frontend/src/components/Turnstile.vue +++ b/frontend/src/components/Turnstile.vue @@ -17,17 +17,21 @@ const { locale, t } = useI18n({ } }); +const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}` const cfTurnstileId = ref("") const turnstileLoading = ref(false) +const refresh = () => checkCfTurnstile(true) +defineExpose({ refresh }) + const checkCfTurnstile = async (remove) => { if (!openSettings.value.cfTurnstileSiteKey) return; turnstileLoading.value = true; try { - let container = document.getElementById("cf-turnstile"); + let container = document.getElementById(containerId); let count = 100; while (!container && count-- > 0) { - container = document.getElementById("cf-turnstile"); + container = document.getElementById(containerId); await new Promise(r => setTimeout(r, 10)); } count = 100; @@ -38,7 +42,7 @@ const checkCfTurnstile = async (remove) => { window.turnstile.remove(cfTurnstileId.value); } cfTurnstileId.value = window.turnstile.render( - "#cf-turnstile", + `#${containerId}`, { sitekey: openSettings.value.cfTurnstileSiteKey, language: locale.value == 'zh' ? 'zh-CN' : 'en-US', @@ -68,7 +72,7 @@ onMounted(() => { -
+
{{ t('refresh') }} diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 4d511eab..8d76d63a 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -39,6 +39,7 @@ export const useGlobalState = createGlobalState( disableAdminPasswordCheck: false, enableAddressPassword: false, statusUrl: '', + enableGlobalTurnstileCheck: false, }) const settings = ref({ fetched: false, diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index f2fdd3eb..f3864f2b 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -5,7 +5,8 @@ import { useRouter } from 'vue-router' import { useGlobalState } from '../store' import { api } from '../api' -import { getRouterPathWithLang } from '../utils' +import { getRouterPathWithLang, hashPassword } from '../utils' +import Turnstile from '../components/Turnstile.vue' import SenderAccess from './admin/SenderAccess.vue' import Statistics from "./admin/Statistics.vue" @@ -44,12 +45,23 @@ const SendMail = defineAsyncComponent(() => { .finally(() => loading.value = false); }); +const cfToken = ref('') +const turnstileRef = ref(null) + const authFunc = async () => { try { + await api.fetch('/open_api/admin_login', { + method: 'POST', + body: JSON.stringify({ + password: await hashPassword(tmpAdminAuth.value), + cf_token: cfToken.value + }) + }); adminAuth.value = tmpAdminAuth.value; location.reload() } catch (error) { message.error(error.message || "error"); + turnstileRef.value?.refresh?.(); } } @@ -169,6 +181,8 @@ const currentLoginMethod = computed(() => { }) onMounted(async () => { + // make sure openSettings is fetched for turnstile check + if (!openSettings.value.fetched) await api.getOpenSettings(message); // make sure user_id is fetched if (!userSettings.value.user_id) await api.getUserSettings(message); }) @@ -180,6 +194,7 @@ onMounted(async () => { preset="dialog" :title="t('accessHeader')">

{{ t('accessTip') }}

+