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:
parent
1edb998487
commit
2c573a5eeb
28
api/middleware/auth.js
Normal file
28
api/middleware/auth.js
Normal 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;
|
||||
@ -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')));
|
||||
|
||||
61
manage.py
61
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():
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 <Login onLogin={handleLogin} />
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<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>
|
||||
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-gray-600" title="Sign out">⏻</button>
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
|
||||
@ -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
|
||||
|
||||
60
ui/src/pages/Login.jsx
Normal file
60
ui/src/pages/Login.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user