fix(security): move account recovery to local CLI, remove unauthenticated reset endpoints

Unauthenticated POST /api/reset-password and /api/reset-account were a
remotely exploitable auth-bypass on public-facing deployments. The confirm
phrase was embedded in the frontend and echoed back by the API, so it was
friction, not authentication: anyone who knew the account email could reset
the password, log in, and obtain a valid JWT.

Recovery now runs as local CLI commands that operate directly on the database
without starting the HTTP server:

  nofx reset-password --email you@example.com
  nofx reset-account

These require shell/file access to the host, which a remote attacker does not
have, so recovery stays safe even when NOFX is exposed to the public internet.

- cli.go: new reset-password / reset-account subcommands (hidden password
  input on a TTY, --password/stdin for scripting, min 8 chars)
- main.go: dispatch subcommands before the server starts (backward compatible
  with the legacy `nofx <dbpath>` arg)
- api: remove public /reset-password and /reset-account routes, their handlers,
  and the public confirm-phrase constants
- web: replace the self-service reset form with CLI instructions; drop the
  AuthContext resetPassword call and the LoginPage reset-account call (en/zh/id)
- telegram: refresh the bot allowlist comment
This commit is contained in:
tinkle-community
2026-06-05 10:49:21 +08:00
parent 2d32a8f6c9
commit 577a0918c3
11 changed files with 335 additions and 389 deletions

View File

@@ -7,7 +7,6 @@ import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { LanguageSwitcher } from '../common/LanguageSwitcher'
import { invalidateSystemConfig } from '../../lib/config'
export function LoginPage() {
const { language } = useLanguage()
@@ -38,31 +37,14 @@ export function LoginPage() {
}
}, [language])
const handleResetAccount = async () => {
if (!window.confirm(t('forgotAccountConfirm', language))) return
try {
const res = await fetch('/api/reset-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirm: 'I_UNDERSTAND_THIS_DELETES_EVERYTHING',
}),
})
if (res.ok) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('user_id')
sessionStorage.removeItem('from401')
invalidateSystemConfig()
toast.success(t('forgotAccountSuccess', language))
setTimeout(() => navigate('/setup'), 1500)
} else {
const data = await res.json()
toast.error(data.error || 'Reset failed')
}
} catch {
toast.error('Network error')
}
// Account wipe was removed from the public API (it was an unauthenticated
// destructive endpoint). It now runs as a local CLI command on the server,
// so we surface the instruction instead of calling an endpoint.
const handleResetAccount = () => {
toast(t('resetAccountCliIntro', language), {
description: 'nofx reset-account',
duration: 10000,
})
}
const handleLogin = async (e: React.FormEvent) => {

View File

@@ -1,57 +1,27 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { Header } from '../common/Header'
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
import PasswordChecklist from 'react-password-checklist'
import { Input } from '../ui/input'
import { ArrowLeft, KeyRound, Copy, Check } from 'lucide-react'
import { toast } from 'sonner'
const RESET_PASSWORD_COMMAND = 'nofx reset-password --email you@example.com'
export function ResetPasswordPage() {
const { language } = useLanguage()
const { resetPassword } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [passwordValid, setPasswordValid] = useState(false)
const [copied, setCopied] = useState(false)
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess(false)
// 验证两次密码是否一致
if (newPassword !== confirmPassword) {
setError(t('passwordMismatch', language))
return
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(RESET_PASSWORD_COMMAND)
setCopied(true)
toast.success(t('copy', language))
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error(t('copy', language))
}
setLoading(true)
const result = await resetPassword(email, newPassword)
if (result.success) {
setSuccess(true)
toast.success(t('resetPasswordSuccess', language) || '重置成功')
// 3秒后跳转到登录页面
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
const msg = result.message || t('resetPasswordFailed', language)
setError(msg)
toast.error(msg)
}
setLoading(false)
}
return (
@@ -84,173 +54,51 @@ export function ResetPasswordPage() {
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('resetPasswordTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
使
</p>
</div>
{/* Reset Password Form */}
{/* CLI recovery instructions */}
<div
className="rounded-lg p-6"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
{success ? (
<div className="text-center py-8">
<div className="text-5xl mb-4"></div>
<p
className="text-lg font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('resetPasswordSuccess', language)}
</p>
<p className="text-sm" style={{ color: '#848E9C' }}>
3...
</p>
</div>
) : (
<form onSubmit={handleResetPassword} className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('email', language)}
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<p
className="text-sm leading-relaxed mb-4"
style={{ color: '#EAECEF' }}
>
{t('resetPasswordCliIntro', language)}
</p>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('newPassword', language)}
</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="pr-10"
placeholder={t('newPasswordPlaceholder', language)}
required
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('confirmPassword', language)}
</label>
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pr-10"
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
{/* 密码强度检查(必须通过才允许提交) */}
<div
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
<div
className="mb-1"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('passwordRequirements', language)}
</div>
<PasswordChecklist
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
value={newPassword}
valueAgain={confirmPassword}
messages={{
minLength: t('passwordRuleMinLength', language),
capital: t('passwordRuleUppercase', language),
lowercase: t('passwordRuleLowercase', language),
number: t('passwordRuleNumber', language),
specialChar: t('passwordRuleSpecial', language),
match: t('passwordRuleMatch', language),
}}
className="space-y-1"
onChange={(isValid) => setPasswordValid(isValid)}
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}}
>
{error}
</div>
<div
className="flex items-center justify-between gap-3 rounded px-3 py-3 font-mono text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<code
className="break-all"
style={{ color: '#F0B90B' }}
>
{RESET_PASSWORD_COMMAND}
</code>
<button
type="button"
onClick={handleCopy}
className="shrink-0 btn-icon"
style={{ color: '#848E9C' }}
aria-label={t('copy', language)}
>
{copied ? (
<Check className="w-4 h-4" style={{ color: '#0ECB81' }} />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<button
type="submit"
disabled={loading || !passwordValid}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading
? t('loading', language)
: t('resetPasswordButton', language)}
</button>
</form>
)}
<p
className="text-xs leading-relaxed mt-4"
style={{ color: '#848E9C' }}
>
{t('resetPasswordCliSecurityNote', language)}
</p>
</div>
</div>
</div>

View File

@@ -33,10 +33,6 @@ interface AuthContextType {
betaCode?: string,
mode?: UserMode
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
newPassword: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
}
@@ -259,36 +255,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}
const resetPassword = async (email: string, newPassword: string) => {
try {
const response = await fetch('/api/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
new_password: newPassword,
// Server-side guard against accidental/drive-by triggers.
// Phrase must match handler_user.go resetPasswordConfirmPhrase.
confirm: 'I_UNDERSTAND_THIS_RESETS_MY_PASSWORD',
}),
})
const data = await response.json()
if (response.ok) {
return { success: true, message: data.message }
} else {
return { success: false, message: data.error }
}
} catch (error) {
return {
success: false,
message: 'Password reset failed, please try again',
}
}
}
// NOTE: in-browser password reset was removed. Recovery now runs as a local
// CLI command on the server (`nofx reset-password`), so it cannot be triggered
// remotely. The reset-password page shows the operator how to run it.
const logout = () => {
const savedToken = localStorage.getItem('auth_token')
@@ -316,7 +285,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
loginAdmin,
register,
resetPassword,
logout,
isLoading,
}}

View File

@@ -506,6 +506,12 @@ export const translations = {
'Password reset successful! Please login with your new password',
resetPasswordFailed: 'Password reset failed',
backToLogin: 'Back to Login',
resetPasswordCliIntro:
'For security, password recovery is no longer available from the browser. Run this command on the server where NOFX is installed:',
resetPasswordCliSecurityNote:
'This requires shell access to the server, which keeps your account safe even when NOFX is exposed to the internet.',
resetAccountCliIntro:
'To wipe everything and start over, run this command on the server where NOFX is installed:',
copy: 'Copy',
loginSuccess: 'Login successful',
registrationSuccess: 'Registration successful',
@@ -1835,6 +1841,12 @@ export const translations = {
resetPasswordSuccess: '密码重置成功!请使用新密码登录',
resetPasswordFailed: '密码重置失败',
backToLogin: '返回登录',
resetPasswordCliIntro:
'出于安全考虑,密码找回不再通过浏览器进行。请在部署 NOFX 的服务器上运行以下命令:',
resetPasswordCliSecurityNote:
'该操作需要服务器的 shell 访问权限,因此即使 NOFX 暴露在公网上,你的账户依然安全。',
resetAccountCliIntro:
'如需清空所有数据并重新开始,请在部署 NOFX 的服务器上运行以下命令:',
copy: '复制',
loginSuccess: '登录成功',
registrationSuccess: '注册成功',
@@ -3100,6 +3112,12 @@ export const translations = {
resetPasswordSuccess: 'Kata sandi berhasil direset! Silakan masuk dengan kata sandi baru',
resetPasswordFailed: 'Gagal mereset kata sandi',
backToLogin: 'Kembali ke Login',
resetPasswordCliIntro:
'Demi keamanan, pemulihan kata sandi tidak lagi tersedia dari browser. Jalankan perintah ini di server tempat NOFX dipasang:',
resetPasswordCliSecurityNote:
'Ini memerlukan akses shell ke server, sehingga akun Anda tetap aman bahkan saat NOFX terekspos ke internet.',
resetAccountCliIntro:
'Untuk menghapus semua data dan memulai dari awal, jalankan perintah ini di server tempat NOFX dipasang:',
copy: 'Salin',
loginSuccess: 'Berhasil masuk',
registrationSuccess: 'Berhasil mendaftar',