diff --git a/api/middleware/auth.js b/api/middleware/auth.js new file mode 100644 index 0000000..3b7a953 --- /dev/null +++ b/api/middleware/auth.js @@ -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; diff --git a/api/server.js b/api/server.js index 8fa027a..5f42c0a 100644 --- a/api/server.js +++ b/api/server.js @@ -23,6 +23,10 @@ const pool = new Pool({ app.use(express.json()); 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 const path = require('path'); app.use(express.static(path.join(__dirname, '../public'))); diff --git a/manage.py b/manage.py index ca4bc47..c4cd77e 100755 --- a/manage.py +++ b/manage.py @@ -205,6 +205,14 @@ def show_status(cfg): print(f' "dataflow" schema {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 public_dir = ROOT / 'public' if ui_built(): @@ -693,6 +701,58 @@ def action_stop_service(): 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 ───────────────────────────────────────────────────────────────── MENU = [ @@ -704,6 +764,7 @@ MENU = [ ('Install dataflow systemd service unit', action_install_service), ('Start / restart dataflow.service', action_restart_service), ('Stop dataflow.service', action_stop_service), + ('Set login credentials', action_set_login_credentials), ] def main(): diff --git a/package.json b/package.json index 61960e5..302e559 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,12 @@ "author": "", "license": "MIT", "dependencies": { - "express": "^4.18.2", - "pg": "^8.11.3", + "bcrypt": "^6.0.0", + "csv-parse": "^5.5.2", "dotenv": "^16.3.1", + "express": "^4.18.2", "multer": "^1.4.5-lts.1", - "csv-parse": "^5.5.2" + "pg": "^8.11.3" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d0b7239..bd0f86a 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' 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 Import from './pages/Import' import Rules from './pages/Rules' @@ -16,27 +17,50 @@ const NAV = [ ] export default function App() { + const [authed, setAuthed] = useState(false) const [sources, setSources] = useState([]) 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(() => { + if (!authed) return api.getSources().then(s => { setSources(s) if (!source && s.length > 0) setSource(s[0].name) - }).catch(() => {}) - }, []) + }).catch(err => { + if (err.status === 401) handleLogout() + }) + }, [authed]) useEffect(() => { if (source) localStorage.setItem('selectedSource', source) }, [source]) + if (!authed) return + return (
{/* Sidebar */}
-
+
Dataflow +
{/* Source selector */} diff --git a/ui/src/api.js b/ui/src/api.js index 95aedf2..9db694e 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -1,7 +1,22 @@ 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) { const opts = { method, headers: {} } + + if (_credentials) { + opts.headers['Authorization'] = `Basic ${btoa(`${_credentials.user}:${_credentials.pass}`)}` + } + if (body) { if (isFormData) { opts.body = body @@ -10,7 +25,16 @@ async function request(method, path, body, isFormData = false) { opts.body = JSON.stringify(body) } } + 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() if (!res.ok) throw new Error(data.error || 'Request failed') return data diff --git a/ui/src/pages/Login.jsx b/ui/src/pages/Login.jsx new file mode 100644 index 0000000..6c29393 --- /dev/null +++ b/ui/src/pages/Login.jsx @@ -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 ( +
+
+

Dataflow

+
+
+ + 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 + /> +
+
+ + 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 + /> +
+ {error &&

{error}

} + +
+
+
+ ) +}