Files
nofx/web/src/contexts/AuthContext.tsx
tinkle-community 577a0918c3 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
2026-06-05 10:49:21 +08:00

304 lines
8.4 KiB
TypeScript

import React, { createContext, useContext, useState, useEffect } from 'react'
import { flushSync } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
import { reset401Flag, httpClient } from '../lib/httpClient'
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
import { ROUTES } from '../router/paths'
import { useLanguage } from './LanguageContext'
interface User {
id: string
email: string
}
interface AuthContextType {
user: User | null
token: string | null
login: (
email: string,
password: string,
mode?: UserMode
) => Promise<{
success: boolean
message?: string
}>
loginAdmin: (password: string) => Promise<{
success: boolean
message?: string
}>
register: (
email: string,
password: string,
betaCode?: string,
mode?: UserMode
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { language } = useLanguage()
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Reset 401 flag on page load to allow fresh 401 handling
reset401Flag()
// Check if admin mode is active (uses cached system config)
getSystemConfig()
.then(() => {
// No longer simulate login in admin mode; check local storage uniformly
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
setIsLoading(false)
})
.catch((err) => {
console.error('Failed to fetch system config:', err)
// On error, continue checking local storage
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
setIsLoading(false)
})
}, [])
// Listen for unauthorized events from httpClient (401 responses)
useEffect(() => {
const handleUnauthorized = () => {
console.log('Unauthorized event received - clearing auth state')
// Clear auth state when 401 is detected
setUser(null)
setToken(null)
// Note: localStorage cleanup is already done in httpClient
}
window.addEventListener('unauthorized', handleUnauthorized)
return () => {
window.removeEventListener('unauthorized', handleUnauthorized)
}
}, [])
const handlePostAuthSuccess = (
authToken: string,
userInfo: User,
mode?: UserMode
) => {
reset401Flag()
if (mode) {
setUserMode(mode)
}
localStorage.setItem('auth_token', authToken)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
localStorage.setItem('user_id', userInfo.id)
flushSync(() => {
setToken(authToken)
setUser(userInfo)
})
const returnUrl = sessionStorage.getItem('returnUrl')
const nextPath = returnUrl || getPostAuthPath(mode)
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
}
navigate(nextPath)
}
const login = async (email: string, password: string, mode?: UserMode) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (response.ok) {
if (data.token) {
const userInfo = { id: data.user_id, email: data.email }
handlePostAuthSuccess(data.token, userInfo, mode)
return { success: true, message: data.message }
}
// Unexpected success response
return {
success: false,
message: data.message || 'Unexpected login response',
}
} else {
return {
success: false,
message: data.error,
}
}
} catch (error) {
return { success: false, message: 'Login failed, please try again' }
}
}
const loginAdmin = async (password: string) => {
try {
const response = await fetch('/api/admin-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
const data = await response.json()
if (response.ok) {
// Reset 401 flag on successful login
reset401Flag()
const userInfo = {
id: data.user_id || 'admin',
email: data.email || 'admin@localhost',
}
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
flushSync(() => {
setToken(data.token)
setUser(userInfo)
})
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
navigate(returnUrl)
} else {
// Redirect to dashboard
navigate(ROUTES.dashboard)
}
return { success: true }
} else {
return { success: false, message: data.error || 'Login failed' }
}
} catch (e) {
return { success: false, message: 'Login failed, please try again' }
}
}
const register = async (
email: string,
password: string,
betaCode?: string,
mode?: UserMode
) => {
const requestBody: {
email: string
password: string
beta_code?: string
lang?: string
} = { email, password, lang: language }
if (betaCode) {
requestBody.beta_code = betaCode
}
try {
const result = await httpClient.post<{
token: string
user_id: string
email: string
message: string
}>('/api/register', requestBody)
if (result.success && result.data) {
// Clear stale onboarding state so new users always see the welcome flow
localStorage.removeItem('nofx_beginner_onboarding_completed')
localStorage.removeItem('nofx_beginner_wallet_address')
const userInfo = { id: result.data.user_id, email: result.data.email }
handlePostAuthSuccess(result.data.token, userInfo, mode)
return {
success: true,
message: result.message || result.data.message,
}
}
// Only business errors reach here (system/network errors were intercepted)
return {
success: false,
message: result.message || 'Registration failed',
}
} catch (error) {
console.error('Auth register error:', error)
// Re-throw if it's a critical error, or return structured error
// Since httpClient throws on 500, we should return a structured error response
// to let the UI display it gracefully without crashing.
return {
success: false,
message:
error instanceof Error ? error.message : 'Detailed server error',
}
}
}
// 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')
if (savedToken) {
fetch('/api/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${savedToken}` },
}).catch(() => {
/* ignore network errors on logout */
})
}
setUser(null)
setToken(null)
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
invalidateSystemConfig()
navigate(ROUTES.home)
}
return (
<AuthContext.Provider
value={{
user,
token,
login,
loginAdmin,
register,
logout,
isLoading,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}