Add login authentication with Basic Auth

- Express auth middleware checks Authorization: Basic header on all /api
  routes using bcrypt against LOGIN_USER/LOGIN_PASSWORD_HASH in .env
- React login screen shown before app loads, stores credentials in memory,
  sends them with every API request, clears and returns to login on 401
- Logout button in sidebar header
- manage.py option 9: set login credentials (bcrypt via node, writes to .env)
- manage.py status shows whether login credentials are configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-05 17:41:07 -04:00
parent 1edb998487
commit 2c573a5eeb
7 changed files with 209 additions and 7 deletions

28
api/middleware/auth.js Normal file
View File

@ -0,0 +1,28 @@
const bcrypt = require('bcrypt');
function authMiddleware(req, res, next) {
const header = req.headers['authorization'];
if (!header || !header.startsWith('Basic ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const [user, pass] = Buffer.from(header.slice(6), 'base64').toString().split(':');
const expectedUser = process.env.LOGIN_USER;
const expectedHash = process.env.LOGIN_PASSWORD_HASH;
if (!expectedUser || !expectedHash) {
return res.status(500).json({ error: 'Login credentials not configured — run manage.py option 9' });
}
if (user !== expectedUser) {
return res.status(401).json({ error: 'Invalid credentials' });
}
bcrypt.compare(pass, expectedHash, (err, match) => {
if (err || !match) return res.status(401).json({ error: 'Invalid credentials' });
next();
});
}
module.exports = authMiddleware;

View File

@ -23,6 +23,10 @@ const pool = new Pool({
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Auth — protect all /api routes (health and static UI are exempt)
const auth = require('./middleware/auth');
app.use('/api', auth);
// Serve UI // Serve UI
const path = require('path'); const path = require('path');
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));

View File

@ -205,6 +205,14 @@ def show_status(cfg):
print(f' "dataflow" schema {dim("unknown — cannot connect to " + db_location)}') print(f' "dataflow" schema {dim("unknown — cannot connect to " + db_location)}')
print(f' SQL functions {dim("unknown — cannot connect to " + db_location)}') print(f' SQL functions {dim("unknown — cannot connect to " + db_location)}')
# Login credentials
login_user = cfg.get('LOGIN_USER', '') if cfg else ''
login_hash = cfg.get('LOGIN_PASSWORD_HASH', '') if cfg else ''
if login_user and login_hash:
print(f' Login {green("configured")} {dim(f"user: {login_user}")}')
else:
print(f' Login {red("not configured")} {dim("run option 9 to set credentials")}')
# UI build # UI build
public_dir = ROOT / 'public' public_dir = ROOT / 'public'
if ui_built(): if ui_built():
@ -693,6 +701,58 @@ def action_stop_service():
ok('dataflow.service stopped') ok('dataflow.service stopped')
def action_set_login_credentials(cfg):
header('Set login credentials (LOGIN_USER / LOGIN_PASSWORD_HASH in .env)')
if not ENV_FILE.exists():
err(f'{ENV_FILE} not found — run option 1 first')
return
print(f' Credentials are stored as a bcrypt hash in {ENV_FILE}.')
print(f' Hashing is done via Node.js bcrypt (requires the API dependencies to be installed).')
print()
user = prompt('Username', cfg.get('LOGIN_USER', '') if cfg else '')
if not user:
info('Cancelled — no changes made')
return
pass1 = getpass.getpass(' Password: ')
pass2 = getpass.getpass(' Confirm password: ')
if pass1 != pass2:
err('Passwords do not match — no changes made')
return
if not pass1:
err('Password cannot be empty — no changes made')
return
print(' Hashing password with bcrypt...')
r = subprocess.run(
['node', '-e',
f"const b=require('bcrypt');b.hash(process.argv[1],12).then(h=>process.stdout.write(h))",
pass1],
capture_output=True, text=True, cwd=ROOT
)
if r.returncode != 0 or not r.stdout:
err(f'Hashing failed — is bcrypt installed? Run: npm install in {ROOT}\n{r.stderr}')
return
hashed = r.stdout.strip()
# Update .env
env_text = ENV_FILE.read_text()
for key, val in [('LOGIN_USER', user), ('LOGIN_PASSWORD_HASH', hashed)]:
if f'{key}=' in env_text:
import re
env_text = re.sub(rf'^{key}=.*$', f'{key}={val}', env_text, flags=re.MULTILINE)
else:
env_text += f'\n{key}={val}\n'
ENV_FILE.write_text(env_text)
ok(f'LOGIN_USER and LOGIN_PASSWORD_HASH written to {ENV_FILE}')
info('Restart the service for changes to take effect (option 7).')
# ── Main menu ───────────────────────────────────────────────────────────────── # ── Main menu ─────────────────────────────────────────────────────────────────
MENU = [ MENU = [
@ -704,6 +764,7 @@ MENU = [
('Install dataflow systemd service unit', action_install_service), ('Install dataflow systemd service unit', action_install_service),
('Start / restart dataflow.service', action_restart_service), ('Start / restart dataflow.service', action_restart_service),
('Stop dataflow.service', action_stop_service), ('Stop dataflow.service', action_stop_service),
('Set login credentials', action_set_login_credentials),
] ]
def main(): def main():

View File

@ -17,11 +17,12 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2", "bcrypt": "^6.0.0",
"pg": "^8.11.3", "csv-parse": "^5.5.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"csv-parse": "^5.5.2" "pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'
import { api } from './api' import { api, setCredentials, clearCredentials } from './api'
import Login from './pages/Login'
import Sources from './pages/Sources' import Sources from './pages/Sources'
import Import from './pages/Import' import Import from './pages/Import'
import Rules from './pages/Rules' import Rules from './pages/Rules'
@ -16,27 +17,50 @@ const NAV = [
] ]
export default function App() { export default function App() {
const [authed, setAuthed] = useState(false)
const [sources, setSources] = useState([]) const [sources, setSources] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '') const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
async function handleLogin(user, pass) {
setCredentials(user, pass)
// Verify credentials by hitting the API throws on 401
await api.getSources().then(s => {
setSources(s)
if (!source && s.length > 0) setSource(s[0].name)
setAuthed(true)
})
}
function handleLogout() {
clearCredentials()
setAuthed(false)
setSources([])
}
useEffect(() => { useEffect(() => {
if (!authed) return
api.getSources().then(s => { api.getSources().then(s => {
setSources(s) setSources(s)
if (!source && s.length > 0) setSource(s[0].name) if (!source && s.length > 0) setSource(s[0].name)
}).catch(() => {}) }).catch(err => {
}, []) if (err.status === 401) handleLogout()
})
}, [authed])
useEffect(() => { useEffect(() => {
if (source) localStorage.setItem('selectedSource', source) if (source) localStorage.setItem('selectedSource', source)
}, [source]) }, [source])
if (!authed) return <Login onLogin={handleLogin} />
return ( return (
<BrowserRouter> <BrowserRouter>
<div className="flex h-screen bg-gray-50"> <div className="flex h-screen bg-gray-50">
{/* Sidebar */} {/* Sidebar */}
<div className="w-44 bg-white border-r border-gray-200 flex flex-col"> <div className="w-44 bg-white border-r border-gray-200 flex flex-col">
<div className="px-4 py-4 border-b border-gray-200"> <div className="px-4 py-4 border-b border-gray-200 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span> <span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-gray-600" title="Sign out"></button>
</div> </div>
{/* Source selector */} {/* Source selector */}

View File

@ -1,7 +1,22 @@
const BASE = '/api' const BASE = '/api'
let _credentials = null // { user, pass }
export function setCredentials(user, pass) {
_credentials = { user, pass }
}
export function clearCredentials() {
_credentials = null
}
async function request(method, path, body, isFormData = false) { async function request(method, path, body, isFormData = false) {
const opts = { method, headers: {} } const opts = { method, headers: {} }
if (_credentials) {
opts.headers['Authorization'] = `Basic ${btoa(`${_credentials.user}:${_credentials.pass}`)}`
}
if (body) { if (body) {
if (isFormData) { if (isFormData) {
opts.body = body opts.body = body
@ -10,7 +25,16 @@ async function request(method, path, body, isFormData = false) {
opts.body = JSON.stringify(body) opts.body = JSON.stringify(body)
} }
} }
const res = await fetch(BASE + path, opts) const res = await fetch(BASE + path, opts)
if (res.status === 401) {
clearCredentials()
const err = new Error('Unauthorized')
err.status = 401
throw err
}
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Request failed') if (!res.ok) throw new Error(data.error || 'Request failed')
return data return data

60
ui/src/pages/Login.jsx Normal file
View File

@ -0,0 +1,60 @@
import { useState } from 'react'
export default function Login({ onLogin }) {
const [user, setUser] = useState('')
const [pass, setPass] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setError('')
setLoading(true)
try {
await onLogin(user, pass)
} catch {
setError('Invalid username or password')
} finally {
setLoading(false)
}
}
return (
<div className="flex items-center justify-center h-screen bg-gray-50">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-80 shadow-sm">
<h1 className="text-lg font-semibold text-gray-800 mb-6">Dataflow</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Username</label>
<input
type="text"
autoFocus
value={user}
onChange={e => setUser(e.target.value)}
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
required
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Password</label>
<input
type="password"
value={pass}
onChange={e => setPass(e.target.value)}
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400"
required
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}