mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-06-25 17:35:07 +08:00
* feat: add Turnstile CAPTCHA for login forms (#767) Add optional Turnstile verification for admin login, user login, and address password login via ENABLE_LOGIN_TURNSTILE_CHECK env var. Does not affect existing Turnstile on address creation / registration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add ENABLE_LOGIN_TURNSTILE_CHECK to wrangler.toml.template Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: ensure openSettings loaded before admin login modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Turnstile to site access password and fix settings field name - Add Turnstile to site access password modal in Header.vue - Add /open_api/site_login endpoint for password + Turnstile verification - Fix settings field name from enableTurnstileLogin to enableLoginTurnstileCheck Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move login endpoints to open_api/auth.ts Move /open_api/site_login and /open_api/admin_login from commom_api.ts to a dedicated open_api/auth.ts file for better code organization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: change Turnstile check failure status from 500 to 400 Turnstile validation failure is a client error, not a server error. Change all Turnstile check error responses from 500 to 400. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use unique IDs for multiple Turnstile instances When multiple modals with Turnstile appear simultaneously (e.g., site access + admin login), the hardcoded id="cf-turnstile" causes conflicts. Generate a unique container ID per Turnstile instance to fix this. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: review fixes - cfToken separation, register Turnstile, error codes - Separate cfToken refs in Login.vue to avoid token sharing between login and new address creation Turnstile instances - Add Turnstile check to user registration endpoint (not just verify_code) - Show Turnstile on register tab regardless of enableMailVerify - Pass cf_token in register request body - Fix site_login error message to use CustomAuthPasswordMsg - Fix verifyCode Turnstile error status from 500 to 400 - Restore empty line in commom_api.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: separate register Turnstile logic for with/without mail verify - With mail verify: verify_code already checks Turnstile, register skips Turnstile (token is one-time use) - Without mail verify: register checks Turnstile directly - Separate loginCfToken for login tab to avoid token sharing with register tab Turnstile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add enableLoginTurnstileCheck to store defaults, simplify changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add /open_api/credential_login for credential login verification Add credential_login endpoint that verifies both Turnstile token and JWT credential server-side, replacing the generic verify_turnstile endpoint. Credential login now validates the JWT before accepting it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve login endpoints - hash passwords, expose Turnstile refresh, fix status codes - site_login/admin_login: always called, verify hashed password + optional Turnstile - credential_login: always called, verify JWT + optional Turnstile - Frontend sends hashed passwords instead of plaintext - Turnstile component exposes refresh method via defineExpose - Fix Turnstile error status 500→400 in mails_api and telegram_api Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: rename to ENABLE_GLOBAL_TURNSTILE_CHECK and add isGlobalTurnstileEnabled helper - Rename ENABLE_LOGIN_TURNSTILE_CHECK -> ENABLE_GLOBAL_TURNSTILE_CHECK - Add isGlobalTurnstileEnabled() in utils.ts: checks env var + Turnstile keys all present - Backend settings returns enableGlobalTurnstileCheck computed from the helper - All backend endpoints use isGlobalTurnstileEnabled(c) instead of raw env check - Update all frontend refs, docs, changelog, and wrangler template Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use utils.isGlobalTurnstileEnabled instead of named import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add E2E tests for turnstile login endpoints - Test all 3 new /open_api/* endpoints when ENABLE_GLOBAL_TURNSTILE_CHECK is disabled - Verify settings returns enableGlobalTurnstileCheck: false - Test admin_login with correct/wrong/empty hashed password - Test site_login returns 401 when no PASSWORDS configured - Test credential_login with valid JWT, invalid JWT, empty credential - Test address_login with empty cf_token works when turnstile disabled - Add ADMIN_PASSWORDS to E2E wrangler config for admin_login tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: rename test file to login-endpoints.spec.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: validate JWT payload has address field in credential_login Prevents user tokens or challenge tokens from being accepted as address credentials since they share the same JWT_SECRET. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: refresh Turnstile token on login failure to allow retry After a failed login attempt, the consumed Turnstile token is now refreshed so users can retry without manually refreshing. Also adds ref to signup Turnstile in UserLogin.vue to refresh after verification code is sent (single-use token consumed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: separate Turnstile tokens for signup and reset password flows Split shared cfToken into signupCfToken and resetCfToken to prevent single-use Turnstile token conflicts between signup tab and reset password modal. Each flow now has its own token ref and refreshes the correct Turnstile widget after use. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update comments from "login turnstile" to "global turnstile" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
### Features
|
||||
|
||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
132
e2e/tests/api/login-endpoints.spec.ts
Normal file
132
e2e/tests/api/login-endpoints.spec.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
<n-spin description="loading..." :show="turnstileLoading">
|
||||
<n-form-item-row>
|
||||
<n-flex vertical>
|
||||
<div id="cf-turnstile"></div>
|
||||
<div :id="containerId"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
|
||||
@@ -39,6 +39,7 @@ export const useGlobalState = createGlobalState(
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
statusUrl: '',
|
||||
enableGlobalTurnstileCheck: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
|
||||
@@ -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')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
@@ -32,11 +33,22 @@ const menuValue = computed(() => {
|
||||
return "home";
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/site_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(auth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +299,7 @@ onMounted(async () => {
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -47,6 +47,8 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
const loginAddress = ref('')
|
||||
const loginPassword = ref('')
|
||||
@@ -72,7 +74,8 @@ const login = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: loginAddress.value,
|
||||
password: await hashPassword(loginPassword.value)
|
||||
password: await hashPassword(loginPassword.value),
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
jwt.value = res.jwt;
|
||||
@@ -85,6 +88,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -93,6 +97,13 @@ const login = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch('/open_api/credential_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credential: credential.value,
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
@@ -103,6 +114,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +301,9 @@ onMounted(async () => {
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
|
||||
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck"
|
||||
v-model:value="loginCfToken" />
|
||||
|
||||
<div class="switch-login-button">
|
||||
<n-button v-if="openSettings?.enableAddressPassword"
|
||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||
|
||||
@@ -69,7 +69,12 @@ const user = ref({
|
||||
password: "",
|
||||
code: ""
|
||||
});
|
||||
const cfToken = ref("")
|
||||
const signupCfToken = ref("")
|
||||
const resetCfToken = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const signupTurnstileRef = ref(null)
|
||||
const resetTurnstileRef = ref(null)
|
||||
const loginTurnstileRef = ref(null)
|
||||
|
||||
const emailLogin = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
@@ -82,13 +87,15 @@ const emailLogin = async () => {
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password)
|
||||
password: await hashPassword(user.value.password),
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +112,8 @@ const sendVerificationCode = async () => {
|
||||
message.error(t('pleaseInputEmail'));
|
||||
return;
|
||||
}
|
||||
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
|
||||
const currentCfToken = showModal.value ? resetCfToken.value : signupCfToken.value;
|
||||
if (openSettings.value.cfTurnstileSiteKey && !currentCfToken && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseCompleteTurnstile'));
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +122,7 @@ const sendVerificationCode = async () => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
cf_token: cfToken.value
|
||||
cf_token: currentCfToken
|
||||
})
|
||||
});
|
||||
if (res && res.expirationTtl) {
|
||||
@@ -131,6 +139,11 @@ const sendVerificationCode = async () => {
|
||||
} catch (error) {
|
||||
message.error(error.message || "send verification code failed");
|
||||
}
|
||||
if (showModal.value) {
|
||||
resetTurnstileRef.value?.refresh?.();
|
||||
} else {
|
||||
signupTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
const emailSignup = async () => {
|
||||
@@ -149,7 +162,8 @@ const emailSignup = async () => {
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password),
|
||||
code: user.value.code
|
||||
code: user.value.code,
|
||||
cf_token: showModal.value ? resetCfToken.value : signupCfToken.value
|
||||
}),
|
||||
message: message
|
||||
});
|
||||
@@ -218,6 +232,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
|
||||
<n-button @click="emailLogin" type="primary" block secondary strong>
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
@@ -248,7 +263,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
|
||||
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
|
||||
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
@@ -259,6 +274,7 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<Turnstile ref="signupTurnstileRef" v-if="!userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
|
||||
</n-form>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('register') }}
|
||||
@@ -273,7 +289,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
|
||||
<n-form-item-row :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
| `ADMIN_CONTACT` | Text | Admin contact information, can be any string, hidden if not configured | `xxx@gmail.com` |
|
||||
| `DISABLE_SHOW_GITHUB` | Text/JSON | Whether to show GitHub link | `true` |
|
||||
| `STATUS_URL` | Text | Status monitoring page URL, shows Status menu button when configured | `https://status.example.com` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
|
||||
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | Text/JSON | Enable global Turnstile CAPTCHA for all login forms (admin login, user login, address password login), requires Turnstile keys above | `true` |
|
||||
|
||||
## Telegram Bot Related Variables
|
||||
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
|
||||
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
|
||||
| `STATUS_URL` | 文本 | 状态监控页面 URL,配置后显示 Status 菜单按钮 | `https://status.example.com` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
|
||||
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | 文本/JSON | 启用全局登录表单的 Turnstile 人机验证(管理员登录、用户登录、邮箱密码登录),需同时配置上述 Turnstile 密钥 | `true` |
|
||||
|
||||
## Telegram Bot 相关变量
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ api.get('/open_api/settings', async (c) => {
|
||||
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
|
||||
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
|
||||
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD),
|
||||
"statusUrl": utils.getStringValue(c.env.STATUS_URL)
|
||||
"statusUrl": utils.getStringValue(c.env.STATUS_URL),
|
||||
"enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c)
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from 'hono';
|
||||
import i18n from '../i18n';
|
||||
import { getBooleanValue, hashPassword } from '../utils';
|
||||
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
|
||||
export default {
|
||||
@@ -49,6 +49,15 @@ export default {
|
||||
return c.text(msgs.EmailPasswordRequiredMsg, 400);
|
||||
}
|
||||
|
||||
// check cf turnstile if global turnstile is enabled
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找地址
|
||||
const address = await c.env.DB.prepare(
|
||||
`SELECT * FROM address WHERE name = ?`
|
||||
|
||||
@@ -130,7 +130,7 @@ api.post('/api/new_address', async (c) => {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 500)
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
// Check if custom email names are disabled from environment variable
|
||||
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
|
||||
|
||||
69
worker/src/open_api/auth.ts
Normal file
69
worker/src/open_api/auth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
|
||||
import i18n from '../i18n';
|
||||
|
||||
const api = new Hono<HonoCustomType>()
|
||||
|
||||
api.post('/open_api/site_login', async (c) => {
|
||||
const { password, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
const passwords = getPasswords(c);
|
||||
const hashedPasswords = await Promise.all(passwords.map(p => hashPassword(p)));
|
||||
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
|
||||
return c.text(msgs.CustomAuthPasswordMsg, 401)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
api.post('/open_api/admin_login', async (c) => {
|
||||
const { password, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
const adminPasswords = getAdminPasswords(c);
|
||||
const hashedPasswords = await Promise.all(adminPasswords.map(p => hashPassword(p)));
|
||||
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
|
||||
return c.text(msgs.NeedAdminPasswordMsg, 401)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
api.post('/open_api/credential_login', async (c) => {
|
||||
const { credential, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
if (!credential) {
|
||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||
}
|
||||
try {
|
||||
const payload = await Jwt.verify(credential, c.env.JWT_SECRET, "HS256");
|
||||
if (!payload.address) {
|
||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
export { api }
|
||||
@@ -89,7 +89,7 @@ async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response>
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 500)
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n';
|
||||
import { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
|
||||
import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { GeoData, UserInfo, UserSettings } from "../models";
|
||||
import { sendMail } from "../mails_api/send_mail_api";
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 500)
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value)
|
||||
@@ -77,11 +77,20 @@ export default {
|
||||
return c.text(msgs.UserRegistrationDisabledMsg, 403);
|
||||
}
|
||||
// check request
|
||||
const { email, password, code } = await c.req.json();
|
||||
const { email, password, code, cf_token } = await c.req.json();
|
||||
if (!email || !password) {
|
||||
return c.text(msgs.InvalidEmailOrPasswordMsg, 400)
|
||||
}
|
||||
checkUserPassword(password);
|
||||
// check cf turnstile only when mail verify is disabled
|
||||
// (when enabled, verify_code endpoint already checks turnstile)
|
||||
if (!settings.enableMailVerify) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
if (settings.enableMailVerify && !code) {
|
||||
return c.text(msgs.InvalidVerifyCodeMsg, 400)
|
||||
}
|
||||
@@ -173,9 +182,17 @@ export default {
|
||||
return c.json({ success: true })
|
||||
},
|
||||
login: async (c: Context<HonoCustomType>) => {
|
||||
const { email, password } = await c.req.json();
|
||||
const { email, password, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!email || !password) return c.text(msgs.InvalidEmailOrPasswordMsg, 400);
|
||||
// check cf turnstile if global turnstile is enabled
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
|
||||
`SELECT id, password FROM users where user_email = ?`
|
||||
).bind(email).first() || {};
|
||||
|
||||
@@ -272,6 +272,12 @@ export const sendAdminInternalMail = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const isGlobalTurnstileEnabled = (c: Context<HonoCustomType>): boolean => {
|
||||
return getBooleanValue(c.env.ENABLE_GLOBAL_TURNSTILE_CHECK)
|
||||
&& !!c.env.CF_TURNSTILE_SITE_KEY
|
||||
&& !!c.env.CF_TURNSTILE_SECRET_KEY;
|
||||
}
|
||||
|
||||
export const checkCfTurnstile = async (
|
||||
c: Context<HonoCustomType>, token: string | undefined | null
|
||||
): Promise<void> => {
|
||||
@@ -369,6 +375,7 @@ export default {
|
||||
checkIsAdmin,
|
||||
getEnvStringList,
|
||||
sendAdminInternalMail,
|
||||
isGlobalTurnstileEnabled,
|
||||
checkCfTurnstile,
|
||||
checkUserPassword,
|
||||
getJsonSetting,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { jwt } from 'hono/jwt'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { api as commonApi } from './commom_api';
|
||||
import { api as openAuthApi } from './open_api/auth';
|
||||
import { api as mailsApi } from './mails_api'
|
||||
import { api as userApi } from './user_api';
|
||||
import { api as adminApi } from './admin_api';
|
||||
@@ -253,6 +254,7 @@ app.use('/admin/*', async (c, next) => {
|
||||
|
||||
|
||||
app.route('/', commonApi)
|
||||
app.route('/', openAuthApi)
|
||||
app.route('/', mailsApi)
|
||||
app.route('/', userApi)
|
||||
app.route('/', adminApi)
|
||||
|
||||
@@ -85,6 +85,8 @@ ENABLE_AUTO_REPLY = false
|
||||
# Turnstile verification
|
||||
# CF_TURNSTILE_SITE_KEY = ""
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# Enable global Turnstile check for all login forms (requires Turnstile keys above)
|
||||
# ENABLE_GLOBAL_TURNSTILE_CHECK = true
|
||||
# telegram bot
|
||||
# TG_MAX_ADDRESS = 5
|
||||
# telegram bot info, predefined bot info can reduce latency of the webhook
|
||||
|
||||
Reference in New Issue
Block a user