mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user