mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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
304 lines
8.4 KiB
TypeScript
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
|
|
}
|