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

@@ -191,123 +191,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password updated"}) c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
} }
// resetPasswordConfirmPhrase is the friction step for /api/reset-password. // NOTE: Password and account recovery used to live here as the public,
// Same security rationale as resetAccountConfirmPhrase — not a cryptographic // unauthenticated handlers handleResetPassword / handleResetAccount. They were
// check, just a guard against accidental and drive-by triggers. // removed because an unauthenticated recovery endpoint is a remotely
const resetPasswordConfirmPhrase = "I_UNDERSTAND_THIS_RESETS_MY_PASSWORD" // 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
// handleResetPassword resets the password for the given email. // rather than authentication. Recovery now lives in the local CLI
// // (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
// SECURITY NOTE: This endpoint is intentionally callable without a JWT — it // shell access to the host — something a remote attacker does not have.
// 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.",
})
}
// initUserDefaultConfigs Initialize default configs for new user // initUserDefaultConfigs Initialize default configs for new user
func (s *Server) initUserDefaultConfigs(userID string, lang string) error { func (s *Server) initUserDefaultConfigs(userID string, lang string) error {

View File

@@ -165,14 +165,13 @@ func (s *Server) setupRoutes() {
// Authentication related routes (no authentication required) // Authentication related routes (no authentication required)
s.route(api, "POST", "/register", "Register new user", s.handleRegister) s.route(api, "POST", "/register", "Register new user", s.handleRegister)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
// SECURITY: /reset-password and /reset-account are PUBLIC by necessity — // SECURITY: password/account recovery is NOT exposed over HTTP. An
// they ARE the recovery paths when the user can no longer log in. Both // unauthenticated recovery endpoint is a remote auth-bypass on any
// require a literal confirmation phrase in the request body, which // public-facing deployment (the confirm phrase is in the frontend and
// blocks accidental triggers and drive-by scripts. The historical // returned by the API, so it is friction, not authentication). Recovery
// takeover path (post-reset wallet-key adoption) was closed by // is now a local CLI run on the host — `nofx reset-password` /
// removing adoptOrphanRecords. See handler_user.go for details. // `nofx reset-account` — which requires shell access the attacker lacks.
s.route(api, "POST", "/reset-password", "Reset password by email (requires confirm phrase)", s.handleResetPassword) // See cli.go.
s.route(api, "POST", "/reset-account", "[DESTRUCTIVE] Wipe everything (requires confirm phrase)", s.handleResetAccount)
// Routes requiring authentication // Routes requiring authentication
protected := api.Group("/", s.authMiddleware()) protected := api.Group("/", s.authMiddleware())

228
cli.go Normal file
View File

@@ -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 <dbpath>` 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
}

1
go.mod
View File

@@ -96,6 +96,7 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.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 google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect

2
go.sum
View File

@@ -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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= 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/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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=

View File

@@ -24,6 +24,14 @@ import (
) )
func main() { 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 // Load .env environment variables
_ = godotenv.Load() _ = godotenv.Load()

View File

@@ -47,7 +47,9 @@ type allowedRoute struct {
// - PUT /api/user/password (password takeover) // - PUT /api/user/password (password takeover)
// - PUT /api/models (LLM API key + endpoint swap → exfil) // - PUT /api/models (LLM API key + endpoint swap → exfil)
// - POST/PUT/DELETE /api/exchanges* (exchange credential swap → drain) // - 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/wallet/generate, /api/wallet/validate
// - POST /api/telegram/* (rebind bot) // - POST /api/telegram/* (rebind bot)
var botAPIAllowlist = []allowedRoute{ var botAPIAllowlist = []allowedRoute{
@@ -197,4 +199,3 @@ func (t *apiCallTool) execute(req *apiRequest) string {
} }
return string(body) return string(body)
} }

View File

@@ -7,7 +7,6 @@ import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
import { DeepVoidBackground } from '../common/DeepVoidBackground' import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { LanguageSwitcher } from '../common/LanguageSwitcher' import { LanguageSwitcher } from '../common/LanguageSwitcher'
import { invalidateSystemConfig } from '../../lib/config'
export function LoginPage() { export function LoginPage() {
const { language } = useLanguage() const { language } = useLanguage()
@@ -38,31 +37,14 @@ export function LoginPage() {
} }
}, [language]) }, [language])
const handleResetAccount = async () => { // Account wipe was removed from the public API (it was an unauthenticated
if (!window.confirm(t('forgotAccountConfirm', language))) return // destructive endpoint). It now runs as a local CLI command on the server,
try { // so we surface the instruction instead of calling an endpoint.
const res = await fetch('/api/reset-account', { const handleResetAccount = () => {
method: 'POST', toast(t('resetAccountCliIntro', language), {
headers: { 'Content-Type': 'application/json' }, description: 'nofx reset-account',
body: JSON.stringify({ duration: 10000,
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')
}
} }
const handleLogin = async (e: React.FormEvent) => { 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 { useNavigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext' import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
import { Header } from '../common/Header' import { Header } from '../common/Header'
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react' import { ArrowLeft, KeyRound, Copy, Check } from 'lucide-react'
import PasswordChecklist from 'react-password-checklist'
import { Input } from '../ui/input'
import { toast } from 'sonner' import { toast } from 'sonner'
const RESET_PASSWORD_COMMAND = 'nofx reset-password --email you@example.com'
export function ResetPasswordPage() { export function ResetPasswordPage() {
const { language } = useLanguage() const { language } = useLanguage()
const { resetPassword } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [email, setEmail] = useState('') const [copied, setCopied] = useState(false)
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 handleResetPassword = async (e: React.FormEvent) => { const handleCopy = async () => {
e.preventDefault() try {
setError('') await navigator.clipboard.writeText(RESET_PASSWORD_COMMAND)
setSuccess(false) setCopied(true)
toast.success(t('copy', language))
// 验证两次密码是否一致 setTimeout(() => setCopied(false), 2000)
if (newPassword !== confirmPassword) { } catch {
setError(t('passwordMismatch', language)) toast.error(t('copy', language))
return
} }
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 ( return (
@@ -84,173 +54,51 @@ export function ResetPasswordPage() {
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}> <h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('resetPasswordTitle', language)} {t('resetPasswordTitle', language)}
</h1> </h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
使
</p>
</div> </div>
{/* Reset Password Form */} {/* CLI recovery instructions */}
<div <div
className="rounded-lg p-6" className="rounded-lg p-6"
style={{ background: '#1E2329', border: '1px solid #2B3139' }} style={{ background: '#1E2329', border: '1px solid #2B3139' }}
> >
{success ? ( <p
<div className="text-center py-8"> className="text-sm leading-relaxed mb-4"
<div className="text-5xl mb-4"></div> style={{ color: '#EAECEF' }}
<p >
className="text-lg font-semibold mb-2" {t('resetPasswordCliIntro', language)}
style={{ color: '#EAECEF' }} </p>
>
{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>
<div> <div
<label className="flex items-center justify-between gap-3 rounded px-3 py-3 font-mono text-xs"
className="block text-sm font-semibold mb-2" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
style={{ color: '#EAECEF' }} >
> <code
{t('newPassword', language)} className="break-all"
</label> style={{ color: '#F0B90B' }}
<div className="relative"> >
<Input {RESET_PASSWORD_COMMAND}
type={showPassword ? 'text' : 'password'} </code>
value={newPassword} <button
onChange={(e) => setNewPassword(e.target.value)} type="button"
className="pr-10" onClick={handleCopy}
placeholder={t('newPasswordPlaceholder', language)} className="shrink-0 btn-icon"
required style={{ color: '#848E9C' }}
/> aria-label={t('copy', language)}
<button >
type="button" {copied ? (
onMouseDown={(e) => e.preventDefault()} <Check className="w-4 h-4" style={{ color: '#0ECB81' }} />
onClick={() => setShowPassword(!showPassword)} ) : (
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon" <Copy className="w-4 h-4" />
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>
)} )}
</button>
</div>
<button <p
type="submit" className="text-xs leading-relaxed mt-4"
disabled={loading || !passwordValid} style={{ color: '#848E9C' }}
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' }} {t('resetPasswordCliSecurityNote', language)}
> </p>
{loading
? t('loading', language)
: t('resetPasswordButton', language)}
</button>
</form>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -33,10 +33,6 @@ interface AuthContextType {
betaCode?: string, betaCode?: string,
mode?: UserMode mode?: UserMode
) => Promise<{ success: boolean; message?: string }> ) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
newPassword: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void logout: () => void
isLoading: boolean isLoading: boolean
} }
@@ -259,36 +255,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
} }
const resetPassword = async (email: string, newPassword: string) => { // NOTE: in-browser password reset was removed. Recovery now runs as a local
try { // CLI command on the server (`nofx reset-password`), so it cannot be triggered
const response = await fetch('/api/reset-password', { // remotely. The reset-password page shows the operator how to run it.
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',
}
}
}
const logout = () => { const logout = () => {
const savedToken = localStorage.getItem('auth_token') const savedToken = localStorage.getItem('auth_token')
@@ -316,7 +285,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login, login,
loginAdmin, loginAdmin,
register, register,
resetPassword,
logout, logout,
isLoading, isLoading,
}} }}

View File

@@ -506,6 +506,12 @@ export const translations = {
'Password reset successful! Please login with your new password', 'Password reset successful! Please login with your new password',
resetPasswordFailed: 'Password reset failed', resetPasswordFailed: 'Password reset failed',
backToLogin: 'Back to Login', 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', copy: 'Copy',
loginSuccess: 'Login successful', loginSuccess: 'Login successful',
registrationSuccess: 'Registration successful', registrationSuccess: 'Registration successful',
@@ -1835,6 +1841,12 @@ export const translations = {
resetPasswordSuccess: '密码重置成功!请使用新密码登录', resetPasswordSuccess: '密码重置成功!请使用新密码登录',
resetPasswordFailed: '密码重置失败', resetPasswordFailed: '密码重置失败',
backToLogin: '返回登录', backToLogin: '返回登录',
resetPasswordCliIntro:
'出于安全考虑,密码找回不再通过浏览器进行。请在部署 NOFX 的服务器上运行以下命令:',
resetPasswordCliSecurityNote:
'该操作需要服务器的 shell 访问权限,因此即使 NOFX 暴露在公网上,你的账户依然安全。',
resetAccountCliIntro:
'如需清空所有数据并重新开始,请在部署 NOFX 的服务器上运行以下命令:',
copy: '复制', copy: '复制',
loginSuccess: '登录成功', loginSuccess: '登录成功',
registrationSuccess: '注册成功', registrationSuccess: '注册成功',
@@ -3100,6 +3112,12 @@ export const translations = {
resetPasswordSuccess: 'Kata sandi berhasil direset! Silakan masuk dengan kata sandi baru', resetPasswordSuccess: 'Kata sandi berhasil direset! Silakan masuk dengan kata sandi baru',
resetPasswordFailed: 'Gagal mereset kata sandi', resetPasswordFailed: 'Gagal mereset kata sandi',
backToLogin: 'Kembali ke Login', 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', copy: 'Salin',
loginSuccess: 'Berhasil masuk', loginSuccess: 'Berhasil masuk',
registrationSuccess: 'Berhasil mendaftar', registrationSuccess: 'Berhasil mendaftar',