From 577a0918c3f179526a866c21c702bf4476fc6a21 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 5 Jun 2026 10:49:21 +0800 Subject: [PATCH] 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 ` 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 --- api/handler_user.go | 125 +-------- api/server.go | 15 +- cli.go | 228 ++++++++++++++++ go.mod | 1 + go.sum | 2 + main.go | 8 + telegram/agent/apicall.go | 5 +- web/src/components/auth/LoginPage.tsx | 34 +-- web/src/components/auth/ResetPasswordPage.tsx | 250 ++++-------------- web/src/contexts/AuthContext.tsx | 38 +-- web/src/i18n/translations.ts | 18 ++ 11 files changed, 335 insertions(+), 389 deletions(-) create mode 100644 cli.go diff --git a/api/handler_user.go b/api/handler_user.go index a57b33c6..af94ba9b 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -191,123 +191,14 @@ func (s *Server) handleChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password updated"}) } -// resetPasswordConfirmPhrase is the friction step for /api/reset-password. -// Same security rationale as resetAccountConfirmPhrase — not a cryptographic -// check, just a guard against accidental and drive-by triggers. -const resetPasswordConfirmPhrase = "I_UNDERSTAND_THIS_RESETS_MY_PASSWORD" - -// handleResetPassword resets the password for the given email. -// -// SECURITY NOTE: This endpoint is intentionally callable without a JWT — it -// IS the recovery path for "forgot password" in the single-user self-hosted -// threat model this project targets. A logged-in user changes password via -// PUT /api/user/password; this endpoint exists for users who can no longer -// log in. Mitigations: -// -// 1. Requires the confirm phrase (blocks accidental and drive-by triggers). -// 2. New password must be ≥ 8 chars. -// 3. Authenticated session change is preferred (PUT /api/user/password). -// -// Operators exposing the API to the public internet should put a reverse-proxy -// auth layer in front of /api/reset-password OR set up out-of-band recovery -// (email link, OTP) instead of relying on this endpoint. -func (s *Server) handleResetPassword(c *gin.Context) { - var req struct { - Email string `json:"email" binding:"required,email"` - NewPassword string `json:"new_password" binding:"required,min=8"` - Confirm string `json:"confirm"` - } - if err := c.ShouldBindJSON(&req); err != nil { - SafeBadRequest(c, "email, new_password (min 8 chars), and confirm are required") - return - } - if req.Confirm != resetPasswordConfirmPhrase { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Confirmation phrase required", - "hint": `Body must include {"confirm":"` + resetPasswordConfirmPhrase + `"}`, - }) - return - } - - user, err := s.store.User().GetByEmail(req.Email) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"}) - return - } - - newPasswordHash, err := auth.HashPassword(req.NewPassword) - if err != nil { - SafeInternalError(c, "Password processing failed", err) - return - } - if err := s.store.User().UpdatePassword(user.ID, newPasswordHash); err != nil { - SafeInternalError(c, "Password update failed", err) - return - } - - logger.Infof("✓ User %s password reset via reset endpoint", user.Email) - c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"}) -} - -// resetAccountConfirmPhrase must appear in the request body for /api/reset-account. -// This is the single intentional friction step that prevents accidental wipes -// from drive-by scripts and crawlers. It is NOT a cryptographic check — anyone -// who reads this source can send the phrase. The real safety comes from: -// -// 1. Wallet keys are NO LONGER auto-adopted by the next registrant -// (adoptOrphanRecords was removed). The historical takeover path was: -// reset → register → inherit prior wallet → drain. That path is closed. -// 2. The destructive action is loud (logged at Warn level). -// -// Operators who expose the API to the public internet and want stronger -// gating can wrap this route with a reverse-proxy auth header check. -const resetAccountConfirmPhrase = "I_UNDERSTAND_THIS_DELETES_EVERYTHING" - -// handleResetAccount wipes all users + traders + strategies + AI models + -// exchanges, returning the system to uninitialized state. -// -// SECURITY NOTE: For the single-user, self-hosted threat model this project -// targets, this endpoint is intentionally callable without a JWT — the -// frontend "forgot account" button must still work after the user forgets -// their password. The confirm phrase blocks accidental and drive-by triggers; -// the removal of orphan adoption blocks the post-reset takeover. A determined -// attacker on a public-facing deployment can still grief by wiping local -// state, but they cannot steal funds (everything is deleted, not transferred). -func (s *Server) handleResetAccount(c *gin.Context) { - var req struct { - Confirm string `json:"confirm"` - } - _ = c.ShouldBindJSON(&req) - if req.Confirm != resetAccountConfirmPhrase { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Confirmation phrase required", - "hint": `Body must include {"confirm":"` + resetAccountConfirmPhrase + `"}`, - }) - return - } - - err := s.store.Transaction(func(tx *gorm.DB) error { - // Wipe ALL records — including wallet keys and exchange credentials. - // Preserving them across user identities is what enabled the takeover. - tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{}) - tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{}) - tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{}) - tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{}) - if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil { - return fmt.Errorf("failed to delete users: %w", err) - } - return nil - }) - if err != nil { - SafeInternalError(c, "Failed to reset account", err) - return - } - - logger.Warnf("⚠ Account reset performed — all users, traders, strategies, ai_models, exchanges wiped") - c.JSON(http.StatusOK, gin.H{ - "message": "System wiped. All wallet keys and exchange credentials were deleted. Register a fresh account and re-import everything.", - }) -} +// NOTE: Password and account recovery used to live here as the public, +// unauthenticated handlers handleResetPassword / handleResetAccount. They were +// removed because an unauthenticated recovery endpoint is a remotely +// exploitable auth-bypass on any public-facing deployment: the confirm phrase +// was embedded in the frontend (and echoed back by the API), so it was friction +// rather than authentication. Recovery now lives in the local CLI +// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires +// shell access to the host — something a remote attacker does not have. // initUserDefaultConfigs Initialize default configs for new user func (s *Server) initUserDefaultConfigs(userID string, lang string) error { diff --git a/api/server.go b/api/server.go index 41a292e3..b8499b15 100644 --- a/api/server.go +++ b/api/server.go @@ -165,14 +165,13 @@ func (s *Server) setupRoutes() { // Authentication related routes (no authentication required) s.route(api, "POST", "/register", "Register new user", s.handleRegister) s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) - // SECURITY: /reset-password and /reset-account are PUBLIC by necessity — - // they ARE the recovery paths when the user can no longer log in. Both - // require a literal confirmation phrase in the request body, which - // blocks accidental triggers and drive-by scripts. The historical - // takeover path (post-reset wallet-key adoption) was closed by - // removing adoptOrphanRecords. See handler_user.go for details. - s.route(api, "POST", "/reset-password", "Reset password by email (requires confirm phrase)", s.handleResetPassword) - s.route(api, "POST", "/reset-account", "[DESTRUCTIVE] Wipe everything (requires confirm phrase)", s.handleResetAccount) + // SECURITY: password/account recovery is NOT exposed over HTTP. An + // unauthenticated recovery endpoint is a remote auth-bypass on any + // public-facing deployment (the confirm phrase is in the frontend and + // returned by the API, so it is friction, not authentication). Recovery + // is now a local CLI run on the host — `nofx reset-password` / + // `nofx reset-account` — which requires shell access the attacker lacks. + // See cli.go. // Routes requiring authentication protected := api.Group("/", s.authMiddleware()) diff --git a/cli.go b/cli.go new file mode 100644 index 00000000..1bbb4f79 --- /dev/null +++ b/cli.go @@ -0,0 +1,228 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "nofx/auth" + "nofx/config" + "nofx/crypto" + "nofx/logger" + "nofx/store" + + "github.com/joho/godotenv" + "golang.org/x/term" + "gorm.io/gorm" +) + +// minResetPasswordLen mirrors the minimum enforced on the authenticated +// password-change path (PUT /api/user/password). +const minResetPasswordLen = 8 + +// runCLISubcommand dispatches local admin subcommands. +// +// SECURITY: account recovery (reset-password, reset-account) is intentionally +// NOT exposed over HTTP. Performing it requires running this binary on the host, +// which in turn requires shell/file access to the server. A remote attacker on a +// public-facing deployment has only the network — they can reach the API but not +// a local process — so recovery cannot be triggered remotely. This is what makes +// the recovery path safe even when NOFX is deployed on the public internet. +// +// Returns true if a subcommand was recognized and handled (caller should exit). +// Unknown first args fall through to false to preserve the historical behavior +// where `nofx ` overrides the SQLite path. +func runCLISubcommand(args []string) bool { + if len(args) == 0 { + return false + } + switch args[0] { + case "reset-password": + runResetPassword(args[1:]) + return true + case "reset-account": + runResetAccount(args[1:]) + return true + default: + return false + } +} + +// openStoreForCLI loads config + encryption and opens the same database the +// server uses, so subcommands operate on the live data. +func openStoreForCLI(dbPathOverride string) (*store.Store, error) { + _ = godotenv.Load() + logger.Init(nil) + config.MustInit() + cfg := config.Get() + if strings.TrimSpace(dbPathOverride) != "" { + cfg.DBPath = dbPathOverride + } + + cryptoService, err := crypto.NewCryptoService() + if err != nil { + return nil, fmt.Errorf("initialize encryption service: %w", err) + } + crypto.SetGlobalCryptoService(cryptoService) + + if cfg.DBType == "sqlite" { + if dir := filepath.Dir(cfg.DBPath); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create data directory: %w", err) + } + } + } + + dbType := store.DBTypeSQLite + if cfg.DBType == "postgres" { + dbType = store.DBTypePostgres + } + return store.NewWithConfig(store.DBConfig{ + Type: dbType, + Path: cfg.DBPath, + Host: cfg.DBHost, + Port: cfg.DBPort, + User: cfg.DBUser, + Password: cfg.DBPassword, + DBName: cfg.DBName, + SSLMode: cfg.DBSSLMode, + }) +} + +// runResetPassword resets the password for a single account from the command +// line. Usage: `nofx reset-password --email you@example.com`. +func runResetPassword(args []string) { + fs := flag.NewFlagSet("reset-password", flag.ExitOnError) + email := fs.String("email", "", "email of the account to reset (required)") + password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively") + dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)") + _ = fs.Parse(args) + + if strings.TrimSpace(*email) == "" { + fmt.Fprintln(os.Stderr, "error: --email is required") + fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com") + os.Exit(2) + } + + st, err := openStoreForCLI(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer st.Close() + + user, err := st.User().GetByEmail(strings.TrimSpace(*email)) + if err != nil { + fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email)) + os.Exit(1) + } + + newPassword, err := resolveNewPassword(*password) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + hash, err := auth.HashPassword(newPassword) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err) + os.Exit(1) + } + if err := st.User().UpdatePassword(user.ID, hash); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email) +} + +// runResetAccount wipes the database back to an uninitialized state. This is the +// destructive "forgot everything" recovery, moved off the public API. +func runResetAccount(args []string) { + fs := flag.NewFlagSet("reset-account", flag.ExitOnError) + dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)") + yes := fs.Bool("yes", false, "skip the interactive confirmation prompt") + _ = fs.Parse(args) + + if !*yes { + fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" + + "exchanges — including wallet keys and exchange credentials.\n" + + "Type 'wipe' to confirm: ") + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + if strings.TrimSpace(line) != "wipe" { + fmt.Fprintln(os.Stderr, "aborted") + os.Exit(1) + } + } + + st, err := openStoreForCLI(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer st.Close() + + err = st.Transaction(func(tx *gorm.DB) error { + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{}) + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{}) + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{}) + tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{}) + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil { + return fmt.Errorf("failed to delete users: %w", err) + } + return nil + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err) + os.Exit(1) + } + + fmt.Println("✓ System wiped. Register a fresh account and re-import everything.") +} + +// resolveNewPassword returns the new password from the --password flag, or +// prompts for it (hidden) on a TTY, or reads a single line from piped stdin. +func resolveNewPassword(flagValue string) (string, error) { + if flagValue != "" { + if len(flagValue) < minResetPasswordLen { + return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen) + } + return flagValue, nil + } + + if term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Printf("New password (min %d chars): ", minResetPasswordLen) + first, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + fmt.Print("Confirm new password: ") + second, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + if string(first) != string(second) { + return "", errors.New("passwords do not match") + } + if len(first) < minResetPasswordLen { + return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen) + } + return string(first), nil + } + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + password := strings.TrimRight(line, "\r\n") + if password == "" { + return "", fmt.Errorf("no password provided on stdin: %w", err) + } + if len(password) < minResetPasswordLen { + return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen) + } + return password, nil +} diff --git a/go.mod b/go.mod index ad323370..be9a705d 100644 --- a/go.mod +++ b/go.mod @@ -96,6 +96,7 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v1.0.1 // indirect diff --git a/go.sum b/go.sum index 40782d01..a7d0a162 100644 --- a/go.sum +++ b/go.sum @@ -251,6 +251,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/main.go b/main.go index 3b85527b..3d7045af 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,14 @@ import ( ) func main() { + // Local admin subcommands (account recovery) run directly against the + // database and never start the HTTP server. Recovery therefore requires + // shell/file access to the host instead of a network request, which keeps + // it safe even when NOFX is exposed to the public internet. See cli.go. + if runCLISubcommand(os.Args[1:]) { + return + } + // Load .env environment variables _ = godotenv.Load() diff --git a/telegram/agent/apicall.go b/telegram/agent/apicall.go index 905cfca7..7c090998 100644 --- a/telegram/agent/apicall.go +++ b/telegram/agent/apicall.go @@ -47,7 +47,9 @@ type allowedRoute struct { // - PUT /api/user/password (password takeover) // - PUT /api/models (LLM API key + endpoint swap → exfil) // - POST/PUT/DELETE /api/exchanges* (exchange credential swap → drain) -// - POST /api/reset-password, /api/reset-account (destructive) +// - account recovery (password reset / account wipe) is intentionally +// CLI-only (`nofx reset-password` / `nofx reset-account`) and has no +// HTTP endpoint, so the bot cannot reach it // - POST /api/wallet/generate, /api/wallet/validate // - POST /api/telegram/* (rebind bot) var botAPIAllowlist = []allowedRoute{ @@ -197,4 +199,3 @@ func (t *apiCallTool) execute(req *apiRequest) string { } return string(body) } - diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index 20029847..33ebe467 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -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) => { diff --git a/web/src/components/auth/ResetPasswordPage.tsx b/web/src/components/auth/ResetPasswordPage.tsx index 0431990c..769f9bdb 100644 --- a/web/src/components/auth/ResetPasswordPage.tsx +++ b/web/src/components/auth/ResetPasswordPage.tsx @@ -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() {

{t('resetPasswordTitle', language)}

-

- 使用邮箱和新密码重置账户密码 -

- {/* Reset Password Form */} + {/* CLI recovery instructions */}
- {success ? ( -
-
-

- {t('resetPasswordSuccess', language)} -

-

- 3秒后将自动跳转到登录页面... -

-
- ) : ( -
-
- - setEmail(e.target.value)} - placeholder={t('emailPlaceholder', language)} - required - /> -
+

+ {t('resetPasswordCliIntro', language)} +

-
- -
- setNewPassword(e.target.value)} - className="pr-10" - placeholder={t('newPasswordPlaceholder', language)} - required - /> - -
-
- -
- -
- setConfirmPassword(e.target.value)} - className="pr-10" - placeholder={t('confirmPasswordPlaceholder', language)} - required - /> - -
-
- - {/* 密码强度检查(必须通过才允许提交) */} -
-
- {t('passwordRequirements', language)} -
- setPasswordValid(isValid)} - /> -
- - {error && ( -
- {error} -
+
+ + {RESET_PASSWORD_COMMAND} + + +
- -
- )} +

+ {t('resetPasswordCliSecurityNote', language)} +

diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 9829050c..dc824204 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -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, }} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 49740ed2..69d53592 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -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',