fix: update 2FA support descriptions and improve error handling in TOTP actions

This commit is contained in:
shuaiplus
2026-03-02 22:36:10 +08:00
parent 16a7bcace9
commit 4da5525a1a
7 changed files with 19 additions and 17 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ English[`README_EN.md`](./README_EN.md)
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send | | Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 | | 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 | | 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET` | | 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 | | SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| 紧急访问 | ✅ | ❌ | 没必要实现 | | 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 | | 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
+1 -1
View File
@@ -36,7 +36,7 @@
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism | | Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
| Send | ✅ | ✅ | Text Send and File Send are supported | | Send | ✅ | ✅ | Text Send and File Send are supported |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement | | Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` | | Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement | | SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Emergency access | ✅ | ❌ | Not necessary to implement | | Emergency access | ✅ | ❌ | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only | | Admin console / Billing & subscription | ✅ | ❌ | Free only |
+1 -1
View File
@@ -71,7 +71,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: null,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET), twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null, accountKeys: null,
+3 -6
View File
@@ -19,13 +19,10 @@ const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) { if (userSecret && isTotpEnabled(userSecret)) {
return userSecret; return userSecret;
} }
if (isTotpEnabled(envSecret)) {
return envSecret!;
}
return null; return null;
} }
@@ -155,9 +152,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
// Optional 2FA: enabled per-user secret first, then falls back to global env secret for compatibility. // Optional 2FA: enabled only by per-user secret.
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) { if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode; const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
+1 -2
View File
@@ -4,7 +4,6 @@ import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers'; import { cipherToResponse } from './ciphers';
import { sendToResponse } from './sends'; import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
interface SyncCacheEntry { interface SyncCacheEntry {
body: string; body: string;
@@ -76,7 +75,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: null,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET), twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null, accountKeys: null,
+4 -2
View File
@@ -611,14 +611,16 @@ export default function App() {
async function enableTotpAction(secret: string, token: string) { async function enableTotpAction(secret: string, token: string) {
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
pushToast('error', t('txt_secret_and_code_are_required')); const error = new Error(t('txt_secret_and_code_are_required'));
return; pushToast('error', error.message);
throw error;
} }
try { try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
pushToast('success', t('txt_totp_enabled')); pushToast('success', t('txt_totp_enabled'));
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
throw error;
} }
} }
+4
View File
@@ -55,10 +55,14 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try {
await props.onEnableTotp(secret, token); await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage. // Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey); localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true); setTotpLocked(true);
} catch {
// Keep inputs editable after a failed attempt.
}
} }
async function loadRecoveryCode(): Promise<void> { async function loadRecoveryCode(): Promise<void> {