The server had no authentication: every /api route was open, CORS allowed any origin, and the identity written to the audit log came from the request body — the UI sent a hardcoded pf_user: 'admin', which any client could have set to anything it liked. Accounts live in pf.app_user with scrypt hashes from node's own crypto, so there is no native build step and the parameters travel with each hash. Sessions are express-session over connect-pg-simple in pf.session: a restart no longer signs everyone out, and a session can be revoked by deleting its row, which is how disable-user cuts off access immediately rather than at cookie expiry. Everything under /api except login/logout/me now requires a session, and the React app is mounted only once there is one — its load effects call the API on mount, so a logged-out mount would just fire a burst of 401s. A session that expires while the app is open lands back on the login screen: auth.jsx wraps fetch once rather than teaching every call site to check. Identity is now read from the session for pf_user, created_by and closed_by, and the body values are ignored. Hardened for an internet-facing deployment: trust proxy so req.ip and secure-cookie detection are right behind TLS termination, httpOnly + SameSite=Lax + Secure cookies, ten login failures per IP per fifteen minutes, one error message for unknown, wrong and disabled alike, and a fresh session id on success. CORS is off entirely unless CORS_ORIGIN names an origin — a wildcard alongside a session cookie would be CSRF by construction. The server refuses to boot without SESSION_SECRET rather than falling back to a guessable default. pf.sh grows add-user, passwd, list-users, disable-user and enable-user; passwords are read on stdin and hashed before they reach psql, so no plaintext in argv or shell history. install.sh generates the secret, applies 02_auth.sql, and creates the first account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
63 lines
2.0 KiB
JavaScript
63 lines
2.0 KiB
JavaScript
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
|
|
|
const AuthContext = createContext()
|
|
|
|
// A session can expire while the app is open. Rather than teach every one of
|
|
// the app's fetch calls to check for it, wrap fetch once: any 401 from /api
|
|
// drops the whole UI back to the login screen. Cookies ride along on their own
|
|
// — fetch defaults to same-origin credentials, and the UI is served from the
|
|
// same origin as the API.
|
|
function installUnauthorizedHandler(onUnauthorized) {
|
|
const original = window.fetch
|
|
window.fetch = async (...args) => {
|
|
const res = await original(...args)
|
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''
|
|
if (res.status === 401 && url.includes('/api/') && !url.includes('/api/login')) {
|
|
onUnauthorized()
|
|
}
|
|
return res
|
|
}
|
|
return () => { window.fetch = original }
|
|
}
|
|
|
|
export function AuthProvider({ children }) {
|
|
const [user, setUser] = useState(null)
|
|
const [checking, setCheck] = useState(true)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/me')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(d => setUser(d?.user || null))
|
|
.catch(() => setUser(null))
|
|
.finally(() => setCheck(false))
|
|
}, [])
|
|
|
|
useEffect(() => installUnauthorizedHandler(() => setUser(null)), [])
|
|
|
|
const login = useCallback(async (username, password) => {
|
|
const r = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
})
|
|
const data = await r.json().catch(() => ({}))
|
|
if (!r.ok) throw new Error(data.error || 'Login failed')
|
|
setUser(data.user)
|
|
return data.user
|
|
}, [])
|
|
|
|
const logout = useCallback(async () => {
|
|
try { await fetch('/api/logout', { method: 'POST' }) } catch {}
|
|
setUser(null)
|
|
}, [])
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, checking, login, logout }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
)
|
|
}
|
|
|
|
const useAuth = () => useContext(AuthContext)
|
|
export default useAuth
|