Compare commits
34 Commits
0cdeabc0e6
...
bd8e2268db
| Author | SHA1 | Date | |
|---|---|---|---|
| bd8e2268db | |||
| 98f5d740f0 | |||
| 7c12fdbdf3 | |||
| 57ce1aca92 | |||
| 98734b1c31 | |||
| 3e12196832 | |||
| c93a611898 | |||
| 4c0f8bd06f | |||
| 2d8d58a9ef | |||
| 939bf38b66 | |||
| 0709e07a05 | |||
| 3b823ab18e | |||
| 646fa747ab | |||
| 8b842f5d74 | |||
| 17d91680d5 | |||
| e993e8aa59 | |||
| b6a8a76f7b | |||
| 12609ba2a4 | |||
| d1ed9cb4b5 | |||
| 7c9431c615 | |||
| a8cb96aa61 | |||
| 96879e75f0 | |||
| c75dc477af | |||
| 0b7b58efe9 | |||
| f4dd7a507f | |||
| 46e20de61b | |||
| 8a80ae71c4 | |||
| 0a03ac60db | |||
| 448a5d7af4 | |||
| e86985743a | |||
| 51b0177f7f | |||
| 61ab979f5a | |||
| 53e1094a10 | |||
| 4bf2086fb8 |
@ -8,3 +8,6 @@ requests==2.32.3
|
|||||||
cryptography==44.0.2
|
cryptography==44.0.2
|
||||||
msal==1.32.0
|
msal==1.32.0
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
pytest==8.3.3
|
||||||
|
httpx==0.27.2
|
||||||
|
|||||||
@ -1,3 +1,47 @@
|
|||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Auth gate: runs before the main app IIFE bootstraps. If the user is not
|
||||||
|
// authenticated, we redirect to /login.html (or /setup.html when the backend
|
||||||
|
// indicates the initial setup is still required) and abort further init.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
(async function authGate() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||||
|
if (r.status === 401) {
|
||||||
|
const setup = await fetch('/api/auth/setup-required').then(function (x) { return x.json(); }).catch(function () { return { setup_required: false }; });
|
||||||
|
window.location.replace(setup.setup_required ? '/setup.html' : '/login.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.ok) return;
|
||||||
|
const me = await r.json();
|
||||||
|
window.__clearviewUser = me;
|
||||||
|
renderUserBadge(me);
|
||||||
|
if (me.role !== 'admin') {
|
||||||
|
const usersLink = document.querySelector('[data-route="users"]');
|
||||||
|
if (usersLink) usersLink.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.location.replace('/login.html');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function renderUserBadge(me) {
|
||||||
|
const slot = document.getElementById('userBadge');
|
||||||
|
if (!slot) return;
|
||||||
|
slot.innerHTML = '';
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'user-badge';
|
||||||
|
wrap.append(document.createTextNode(me.username + ' (' + me.role + ')'));
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = 'Sign out';
|
||||||
|
btn.addEventListener('click', async function () {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
window.location.replace('/login.html');
|
||||||
|
});
|
||||||
|
wrap.append(btn);
|
||||||
|
slot.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const state = {
|
const state = {
|
||||||
selectedJobId: null,
|
selectedJobId: null,
|
||||||
@ -15,6 +59,7 @@
|
|||||||
'scan-mailbox': 'New Mailbox Scan',
|
'scan-mailbox': 'New Mailbox Scan',
|
||||||
'scan-entra': 'New Entra Group Scan',
|
'scan-entra': 'New Entra Group Scan',
|
||||||
'tenants': 'Tenants',
|
'tenants': 'Tenants',
|
||||||
|
'users': 'Users',
|
||||||
'settings': 'Settings',
|
'settings': 'Settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -113,7 +158,11 @@
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async function requestJson(url, options) {
|
async function requestJson(url, options) {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options || {}));
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.replace('/login.html');
|
||||||
|
throw new Error('unauthenticated');
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = response.statusText;
|
let detail = response.statusText;
|
||||||
try {
|
try {
|
||||||
@ -1551,6 +1600,181 @@
|
|||||||
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
|
if (els.csvEntraForm) els.csvEntraForm.addEventListener('submit', createCsvEntraJob);
|
||||||
if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob);
|
if (els.allEntraForm) els.allEntraForm.addEventListener('submit', createAllEntraJob);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Users + Audit admin view
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function renderUsersView() {
|
||||||
|
const root = document.getElementById('usersViewRoot');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = '<section class="users-view"><h2>Users</h2>' +
|
||||||
|
'<div class="users-toolbar"><button id="newUserBtn" class="btn btn-primary">New user</button></div>' +
|
||||||
|
'<div id="usersTable"></div>' +
|
||||||
|
'<h3 style="margin-top:24px">Audit log</h3>' +
|
||||||
|
'<div id="auditTable"></div></section>';
|
||||||
|
document.getElementById('newUserBtn').addEventListener('click', function () { openUserModal(null); });
|
||||||
|
await reloadUsersTable();
|
||||||
|
await reloadAuditTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadUsersTable() {
|
||||||
|
const target = document.getElementById('usersTable');
|
||||||
|
if (!target) return;
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await requestJson('/api/users');
|
||||||
|
} catch (e) {
|
||||||
|
target.innerHTML = '<p class="auth-error">' + escHtml((e && e.message) || 'Load failed') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = '';
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
tbl.innerHTML = '<thead><tr><th>Username</th><th>Role</th><th>Active</th><th>Created</th><th></th></tr></thead>';
|
||||||
|
const tb = document.createElement('tbody');
|
||||||
|
rows.forEach(function (u) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td>' + escHtml(u.username) + '</td>' +
|
||||||
|
'<td>' + escHtml(u.role) + '</td>' +
|
||||||
|
'<td>' + (u.is_active ? 'yes' : 'no') + '</td>' +
|
||||||
|
'<td>' + escHtml(new Date(u.created_at).toLocaleString()) + '</td>';
|
||||||
|
const actions = document.createElement('td');
|
||||||
|
const edit = Object.assign(document.createElement('button'), { textContent: 'Edit', className: 'btn btn-outline btn-small' });
|
||||||
|
edit.addEventListener('click', function () { openUserModal(u); });
|
||||||
|
const reset = Object.assign(document.createElement('button'), { textContent: 'Reset pw', className: 'btn btn-outline btn-small' });
|
||||||
|
reset.addEventListener('click', function () { openResetModal(u); });
|
||||||
|
const del = Object.assign(document.createElement('button'), { textContent: 'Delete', className: 'btn btn-outline btn-small' });
|
||||||
|
del.addEventListener('click', async function () {
|
||||||
|
if (!confirm('Delete ' + u.username + '?')) return;
|
||||||
|
const r = await fetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
if (!r.ok) { alert('Delete failed'); return; }
|
||||||
|
await reloadUsersTable();
|
||||||
|
await reloadAuditTable();
|
||||||
|
});
|
||||||
|
actions.append(edit, ' ', reset, ' ', del);
|
||||||
|
tr.append(actions);
|
||||||
|
tb.append(tr);
|
||||||
|
});
|
||||||
|
tbl.append(tb);
|
||||||
|
target.append(tbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserModal(existing) {
|
||||||
|
const isNew = !existing;
|
||||||
|
const usernameSafe = existing ? escHtml(existing.username) : '';
|
||||||
|
const html = '<div class="modal-back"><div class="modal">' +
|
||||||
|
'<h3>' + (isNew ? 'New user' : 'Edit ' + usernameSafe) + '</h3>' +
|
||||||
|
'<label>Username<input id="muUsername" ' + (isNew ? '' : 'disabled') + ' value="' + usernameSafe + '" /></label>' +
|
||||||
|
(isNew ? '<label>Password<input id="muPassword" type="password" /></label>' : '') +
|
||||||
|
'<label>Role<select id="muRole">' +
|
||||||
|
'<option value="user"' + (existing && existing.role === 'user' ? ' selected' : '') + '>user</option>' +
|
||||||
|
'<option value="admin"' + (existing && existing.role === 'admin' ? ' selected' : '') + '>admin</option>' +
|
||||||
|
'</select></label>' +
|
||||||
|
(isNew ? '' : '<label><input id="muActive" type="checkbox"' + (existing.is_active ? ' checked' : '') + ' /> Active</label>') +
|
||||||
|
'<p id="muError" class="auth-error" hidden></p>' +
|
||||||
|
'<div class="modal-actions">' +
|
||||||
|
'<button id="muCancel" class="btn btn-outline">Cancel</button>' +
|
||||||
|
'<button id="muSave" class="btn btn-primary">' + (isNew ? 'Create' : 'Save') + '</button>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
document.body.append(wrap);
|
||||||
|
const close = function () { wrap.remove(); };
|
||||||
|
document.getElementById('muCancel').addEventListener('click', close);
|
||||||
|
document.getElementById('muSave').addEventListener('click', async function () {
|
||||||
|
const err = document.getElementById('muError');
|
||||||
|
err.hidden = true;
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await requestJson('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('muUsername').value,
|
||||||
|
password: document.getElementById('muPassword').value,
|
||||||
|
role: document.getElementById('muRole').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await requestJson('/api/users/' + encodeURIComponent(existing.id), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: document.getElementById('muRole').value,
|
||||||
|
is_active: document.getElementById('muActive').checked,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
await reloadUsersTable();
|
||||||
|
await reloadAuditTable();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = (e && e.message) || 'Save failed';
|
||||||
|
err.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetModal(u) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.innerHTML = '<div class="modal-back"><div class="modal">' +
|
||||||
|
'<h3>Reset password for ' + escHtml(u.username) + '</h3>' +
|
||||||
|
'<label>New password<input id="rpPw" type="password" /></label>' +
|
||||||
|
'<p id="rpErr" class="auth-error" hidden></p>' +
|
||||||
|
'<div class="modal-actions">' +
|
||||||
|
'<button id="rpCancel" class="btn btn-outline">Cancel</button>' +
|
||||||
|
'<button id="rpSave" class="btn btn-primary">Reset</button>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
document.body.append(wrap);
|
||||||
|
const close = function () { wrap.remove(); };
|
||||||
|
document.getElementById('rpCancel').addEventListener('click', close);
|
||||||
|
document.getElementById('rpSave').addEventListener('click', async function () {
|
||||||
|
const err = document.getElementById('rpErr');
|
||||||
|
err.hidden = true;
|
||||||
|
try {
|
||||||
|
await requestJson('/api/users/' + encodeURIComponent(u.id) + '/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: document.getElementById('rpPw').value }),
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
await reloadAuditTable();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = (e && e.message) || 'Reset failed';
|
||||||
|
err.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAuditTable() {
|
||||||
|
const target = document.getElementById('auditTable');
|
||||||
|
if (!target) return;
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await requestJson('/api/audit?limit=100');
|
||||||
|
} catch (e) {
|
||||||
|
target.innerHTML = '<p class="auth-error">' + escHtml((e && e.message) || 'Load failed') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = '';
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
tbl.innerHTML = '<thead><tr><th>When</th><th>Event</th><th>User</th><th>IP</th><th>Detail</th></tr></thead>';
|
||||||
|
const tb = document.createElement('tbody');
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const detailText = r.detail ? JSON.stringify(r.detail) : '';
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td>' + escHtml(new Date(r.ts).toLocaleString()) + '</td>' +
|
||||||
|
'<td>' + escHtml(r.event) + '</td>' +
|
||||||
|
'<td>' + escHtml(r.user_id == null ? '' : String(r.user_id)) + '</td>' +
|
||||||
|
'<td>' + escHtml(r.ip == null ? '' : String(r.ip)) + '</td>' +
|
||||||
|
'<td>' + escHtml(detailText) + '</td>';
|
||||||
|
tb.append(tr);
|
||||||
|
});
|
||||||
|
tbl.append(tb);
|
||||||
|
target.append(tbl);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Hash router
|
// Hash router
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -1591,6 +1815,9 @@
|
|||||||
els.contentTitle.textContent = ROUTE_TITLES[route];
|
els.contentTitle.textContent = ROUTE_TITLES[route];
|
||||||
}
|
}
|
||||||
document.title = 'Clearview | ' + ROUTE_TITLES[route];
|
document.title = 'Clearview | ' + ROUTE_TITLES[route];
|
||||||
|
if (route === 'users') {
|
||||||
|
renderUsersView().catch(function (err) { console.error('Users view failed', err); });
|
||||||
|
}
|
||||||
// On user navigation, move focus to the new page's first heading so
|
// On user navigation, move focus to the new page's first heading so
|
||||||
// screen-reader and keyboard users land in the freshly shown content.
|
// screen-reader and keyboard users land in the freshly shown content.
|
||||||
if (moveFocus && activePage) {
|
if (moveFocus && activePage) {
|
||||||
|
|||||||
22
containers/clearview/site/auth.js
Normal file
22
containers/clearview/site/auth.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
(function (global) {
|
||||||
|
async function postJson(url, body) {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
let data = null;
|
||||||
|
try { data = await r.json(); } catch (_) {}
|
||||||
|
return { ok: r.ok, status: r.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(url) {
|
||||||
|
const r = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
let data = null;
|
||||||
|
try { data = await r.json(); } catch (_) {}
|
||||||
|
return { ok: r.ok, status: r.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
global.ClearviewAuth = { postJson, getJson };
|
||||||
|
})(window);
|
||||||
@ -32,6 +32,7 @@
|
|||||||
<div class="nav-section">Entra</div>
|
<div class="nav-section">Entra</div>
|
||||||
<a href="#/scan/entra" class="nav-link" data-route="scan-entra">New Entra Scan</a>
|
<a href="#/scan/entra" class="nav-link" data-route="scan-entra">New Entra Scan</a>
|
||||||
|
|
||||||
|
<a href="#/users" class="nav-link" data-route="users">Users</a>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
<a href="#/tenants" class="nav-link" data-route="tenants">Tenants</a>
|
<a href="#/tenants" class="nav-link" data-route="tenants">Tenants</a>
|
||||||
<a href="#/settings" class="nav-link" data-route="settings">Settings</a>
|
<a href="#/settings" class="nav-link" data-route="settings">Settings</a>
|
||||||
@ -46,6 +47,7 @@
|
|||||||
<div class="content-title" id="contentTitle">Dashboard</div>
|
<div class="content-title" id="contentTitle">Dashboard</div>
|
||||||
<div class="content-actions">
|
<div class="content-actions">
|
||||||
<button id="refreshJobsBtn" class="btn btn-outline" type="button">Refresh</button>
|
<button id="refreshJobsBtn" class="btn btn-outline" type="button">Refresh</button>
|
||||||
|
<div class="header-user" id="userBadge"></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -570,6 +572,10 @@
|
|||||||
<!-- =================================================================== -->
|
<!-- =================================================================== -->
|
||||||
<!-- Route: Settings (placeholder) -->
|
<!-- Route: Settings (placeholder) -->
|
||||||
<!-- =================================================================== -->
|
<!-- =================================================================== -->
|
||||||
|
<section class="route-page" data-route-page="users" hidden>
|
||||||
|
<div id="usersViewRoot"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="route-page" data-route-page="settings" hidden>
|
<section class="route-page" data-route-page="settings" hidden>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header split">
|
<div class="panel-header split">
|
||||||
|
|||||||
49
containers/clearview/site/login.html
Normal file
49
containers/clearview/site/login.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Clearview — Sign in</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="auth-page">
|
||||||
|
<main class="auth-card">
|
||||||
|
<h1>Clearview</h1>
|
||||||
|
<p class="auth-sub">Sign in to continue</p>
|
||||||
|
<form id="loginForm">
|
||||||
|
<label>Username<input name="username" autocomplete="username" required autofocus /></label>
|
||||||
|
<label>Password<input name="password" type="password" autocomplete="current-password" required /></label>
|
||||||
|
<label class="auth-remember"><input name="remember" type="checkbox" /> Remember me for 30 days</label>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
<p id="loginError" class="auth-error" hidden></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="/auth.js"></script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const setup = await ClearviewAuth.getJson('/api/auth/setup-required');
|
||||||
|
if (setup.ok && setup.data && setup.data.setup_required) {
|
||||||
|
window.location.replace('/setup.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const err = document.getElementById('loginError');
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
err.hidden = true;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const res = await ClearviewAuth.postJson('/api/auth/login', {
|
||||||
|
username: fd.get('username'),
|
||||||
|
password: fd.get('password'),
|
||||||
|
remember: fd.get('remember') === 'on',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.replace('/');
|
||||||
|
} else {
|
||||||
|
err.textContent = (res.data && res.data.detail) || 'Sign-in failed';
|
||||||
|
err.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
containers/clearview/site/setup.html
Normal file
47
containers/clearview/site/setup.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Clearview — First-time setup</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="auth-page">
|
||||||
|
<main class="auth-card">
|
||||||
|
<h1>Welcome to Clearview</h1>
|
||||||
|
<p class="auth-sub">Create the first administrator account.</p>
|
||||||
|
<form id="setupForm">
|
||||||
|
<label>Username<input name="username" autocomplete="username" required autofocus /></label>
|
||||||
|
<label>Password (≥12 chars, letter + digit)<input name="password" type="password" autocomplete="new-password" required minlength="12" /></label>
|
||||||
|
<button type="submit">Create administrator</button>
|
||||||
|
<p id="setupError" class="auth-error" hidden></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="/auth.js"></script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const probe = await ClearviewAuth.getJson('/api/auth/setup-required');
|
||||||
|
if (!probe.ok || !probe.data || !probe.data.setup_required) {
|
||||||
|
window.location.replace('/login.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = document.getElementById('setupForm');
|
||||||
|
const err = document.getElementById('setupError');
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
err.hidden = true;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const res = await ClearviewAuth.postJson('/api/auth/setup', {
|
||||||
|
username: fd.get('username'),
|
||||||
|
password: fd.get('password'),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.replace('/');
|
||||||
|
} else {
|
||||||
|
err.textContent = (res.data && res.data.detail) || 'Setup failed';
|
||||||
|
err.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -752,3 +752,28 @@ strong {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Auth (login / setup) pages and header badge ============================== */
|
||||||
|
.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0f1115; margin: 0; }
|
||||||
|
.auth-card { width: 360px; max-width: 92vw; padding: 28px; background: #1a1d24; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,.35); color: #e6e8ee; font-family: system-ui, sans-serif; }
|
||||||
|
.auth-card h1 { margin: 0 0 4px; font-size: 22px; }
|
||||||
|
.auth-sub { margin: 0 0 18px; opacity: .75; }
|
||||||
|
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.auth-card label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||||
|
.auth-card input[type=text], .auth-card input[type=password], .auth-card input:not([type]) { padding: 8px 10px; background: #0e1116; border: 1px solid #2a2f3a; border-radius: 6px; color: inherit; }
|
||||||
|
.auth-card .auth-remember { flex-direction: row; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.auth-card button { padding: 10px; background: #3b82f6; border: 0; border-radius: 6px; color: #fff; font-weight: 600; cursor: pointer; }
|
||||||
|
.auth-card button:hover { background: #2563eb; }
|
||||||
|
.auth-error { background: #3a1f25; color: #fda4af; padding: 8px 10px; border-radius: 6px; font-size: 13px; }
|
||||||
|
.user-badge { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border: 1px solid #2a2f3a; border-radius: 999px; font-size: 12px; }
|
||||||
|
.user-badge button { background: transparent; border: 0; color: #93c5fd; cursor: pointer; padding: 0; }
|
||||||
|
.user-badge button:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Users / audit admin view */
|
||||||
|
.modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
|
.modal { background: #1a1d24; color: #e6e8ee; padding: 20px; border-radius: 10px; min-width: 320px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.modal label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.users-view table { width: 100%; border-collapse: collapse; }
|
||||||
|
.users-view th, .users-view td { padding: 6px 8px; border-bottom: 1px solid #2a2f3a; text-align: left; }
|
||||||
|
.users-toolbar { margin: 12px 0; }
|
||||||
|
|||||||
1
containers/clearview/src/clearview_app/auth/__init__.py
Normal file
1
containers/clearview/src/clearview_app/auth/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Authentication, session, and user-management subsystem."""
|
||||||
20
containers/clearview/src/clearview_app/auth/audit.py
Normal file
20
containers/clearview/src/clearview_app/auth/audit.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""Single-entry helper for writing rows to the auth audit log."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import AuthAudit
|
||||||
|
|
||||||
|
|
||||||
|
def record_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
user_id: int | None = None,
|
||||||
|
ip: str | None = None,
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add an AuthAudit row to the session. Caller commits."""
|
||||||
|
db.add(AuthAudit(event=event, user_id=user_id, ip=ip, detail=detail))
|
||||||
52
containers/clearview/src/clearview_app/auth/dependencies.py
Normal file
52
containers/clearview/src/clearview_app/auth/dependencies.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""FastAPI dependencies that gate API endpoints behind a session."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Cookie, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import COOKIE_NAME # noqa: F401
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from . import sessions as S
|
||||||
|
from .models import User, UserSession
|
||||||
|
|
||||||
|
|
||||||
|
AuthedUser = User
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_session(db: Session, sid: str | None) -> tuple[User, UserSession]:
|
||||||
|
session = S.lookup_and_refresh(db, sid)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
user = db.get(User, session.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
db.commit()
|
||||||
|
return user, session
|
||||||
|
|
||||||
|
|
||||||
|
def require_user(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
clearview_session: Annotated[str | None, Cookie()] = None,
|
||||||
|
) -> User:
|
||||||
|
user, _ = _load_session(db, clearview_session)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
clearview_session: Annotated[str | None, Cookie()] = None,
|
||||||
|
) -> User:
|
||||||
|
user, _ = _load_session(db, clearview_session)
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
|
||||||
|
return user
|
||||||
87
containers/clearview/src/clearview_app/auth/models.py
Normal file
87
containers/clearview/src/clearview_app/auth/models.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""SQLAlchemy models for authentication, sessions, and audit log.
|
||||||
|
|
||||||
|
A dedicated ``Base`` is used so these tables can be created independently
|
||||||
|
of the existing scan/tenant models in tests; in production they coexist
|
||||||
|
in the same database under Alembic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, TypeDecorator
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class UTCDateTime(TypeDecorator):
|
||||||
|
"""DateTime that always returns tz-aware UTC values.
|
||||||
|
|
||||||
|
SQLite (used in tests) does not preserve tzinfo on roundtrip even with
|
||||||
|
``DateTime(timezone=True)``. This decorator normalises stored and loaded
|
||||||
|
values to UTC-aware datetimes so app code can rely on tz arithmetic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
impl = DateTime(timezone=True)
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(Base):
|
||||||
|
__tablename__ = "user_sessions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False, index=True)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
remember: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthAudit(Base):
|
||||||
|
__tablename__ = "auth_audit"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
ts: Mapped[datetime] = mapped_column(UTCDateTime(), default=_utcnow, nullable=False, index=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
event: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
detail: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
139
containers/clearview/src/clearview_app/auth/router.py
Normal file
139
containers/clearview/src/clearview_app/auth/router.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""Routes for login, logout, identity, and initial setup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE
|
||||||
|
from . import sessions as S
|
||||||
|
from .audit import record_event
|
||||||
|
from .dependencies import get_db, require_user
|
||||||
|
from .models import User, UserSession
|
||||||
|
from .schemas import LoginRequest, MeResponse, SetupRequest, SetupRequiredResponse
|
||||||
|
from .security import (
|
||||||
|
PasswordPolicyError,
|
||||||
|
hash_password,
|
||||||
|
validate_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _ip(request: Request) -> str | None:
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, sid: str, *, remember: bool) -> None:
|
||||||
|
max_age = 30 * 24 * 3600 if remember else 8 * 3600
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=sid,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
secure=COOKIE_SECURE,
|
||||||
|
samesite=COOKIE_SAMESITE,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def _users_count(db: Session) -> int:
|
||||||
|
return db.execute(select(func.count(User.id))).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/auth/setup-required", response_model=SetupRequiredResponse)
|
||||||
|
def setup_required(db: Annotated[Session, Depends(get_db)]) -> SetupRequiredResponse:
|
||||||
|
return SetupRequiredResponse(setup_required=_users_count(db) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/auth/setup", response_model=MeResponse)
|
||||||
|
def setup(
|
||||||
|
payload: SetupRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> MeResponse:
|
||||||
|
if _users_count(db) > 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed")
|
||||||
|
try:
|
||||||
|
validate_password(payload.password)
|
||||||
|
except PasswordPolicyError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=payload.username,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user); db.flush()
|
||||||
|
|
||||||
|
sid, _ = S.create_session(
|
||||||
|
db, user_id=user.id, remember=False, ip=_ip(request), user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
record_event(db, event="setup", user_id=user.id, ip=_ip(request), detail={"username": user.username})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_set_cookie(response, sid, remember=False)
|
||||||
|
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/auth/login", response_model=MeResponse)
|
||||||
|
def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> MeResponse:
|
||||||
|
user = db.execute(select(User).where(User.username == payload.username)).scalar_one_or_none()
|
||||||
|
if user is None or not user.is_active or not verify_password(payload.password, user.password_hash):
|
||||||
|
record_event(
|
||||||
|
db,
|
||||||
|
event="login_fail",
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
ip=_ip(request),
|
||||||
|
detail={"username": payload.username},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
sid, _ = S.create_session(
|
||||||
|
db, user_id=user.id, remember=payload.remember, ip=_ip(request), user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
record_event(db, event="login_ok", user_id=user.id, ip=_ip(request), detail=None)
|
||||||
|
S.purge_expired(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_set_cookie(response, sid, remember=payload.remember)
|
||||||
|
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/auth/logout")
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
sid = request.cookies.get(COOKIE_NAME)
|
||||||
|
user_id: int | None = None
|
||||||
|
if sid:
|
||||||
|
existing = db.get(UserSession, sid)
|
||||||
|
if existing is not None:
|
||||||
|
user_id = existing.user_id
|
||||||
|
S.revoke(db, sid)
|
||||||
|
record_event(db, event="logout", user_id=user_id, ip=_ip(request), detail=None)
|
||||||
|
db.commit()
|
||||||
|
_clear_cookie(response)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/auth/me", response_model=MeResponse)
|
||||||
|
def me(user: Annotated[User, Depends(require_user)]) -> MeResponse:
|
||||||
|
return MeResponse(username=user.username, role=user.role) # type: ignore[arg-type]
|
||||||
59
containers/clearview/src/clearview_app/auth/schemas.py
Normal file
59
containers/clearview/src/clearview_app/auth/schemas.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Pydantic schemas for the auth and users routers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
remember: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SetupRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: Literal["admin", "user"]
|
||||||
|
|
||||||
|
|
||||||
|
class SetupRequiredResponse(BaseModel):
|
||||||
|
setup_required: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: Literal["admin", "user"]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
role: Literal["admin", "user"] = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
role: Literal["admin", "user"] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
ts: datetime
|
||||||
|
user_id: int | None
|
||||||
|
event: str
|
||||||
|
ip: str | None
|
||||||
|
detail: dict | None
|
||||||
44
containers/clearview/src/clearview_app/auth/security.py
Normal file
44
containers/clearview/src/clearview_app/auth/security.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Password hashing, password-policy validation, and session-id generation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordPolicyError(ValueError):
|
||||||
|
"""Raised when a candidate password does not meet the policy."""
|
||||||
|
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
MIN_LENGTH = 12
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(pw: str) -> None:
|
||||||
|
"""Enforce: length >= 12, at least one letter and one digit."""
|
||||||
|
if len(pw) < MIN_LENGTH:
|
||||||
|
raise PasswordPolicyError(f"Password must be at least {MIN_LENGTH} characters.")
|
||||||
|
if not any(c.isalpha() for c in pw):
|
||||||
|
raise PasswordPolicyError("Password must contain at least one letter.")
|
||||||
|
if not any(c.isdigit() for c in pw):
|
||||||
|
raise PasswordPolicyError("Password must contain at least one digit.")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(pw: str) -> str:
|
||||||
|
return _hasher.hash(pw)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(pw: str, encoded: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(encoded, pw)
|
||||||
|
except (VerifyMismatchError, InvalidHashError, VerificationError):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def new_session_id() -> str:
|
||||||
|
"""Opaque 128-bit session identifier rendered as 32 hex chars."""
|
||||||
|
return uuid.uuid4().hex
|
||||||
72
containers/clearview/src/clearview_app/auth/sessions.py
Normal file
72
containers/clearview/src/clearview_app/auth/sessions.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Session lifecycle: create, look up + refresh, revoke, purge expired."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import UserSession
|
||||||
|
from .security import new_session_id
|
||||||
|
|
||||||
|
SLIDING_TTL = timedelta(hours=8)
|
||||||
|
REMEMBER_TTL = timedelta(days=30)
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
remember: bool,
|
||||||
|
ip: str | None,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> tuple[str, datetime]:
|
||||||
|
ttl = REMEMBER_TTL if remember else SLIDING_TTL
|
||||||
|
expires = _utcnow() + ttl
|
||||||
|
sid = new_session_id()
|
||||||
|
db.add(
|
||||||
|
UserSession(
|
||||||
|
id=sid,
|
||||||
|
user_id=user_id,
|
||||||
|
expires_at=expires,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
remember=remember,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
return sid, expires
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_and_refresh(db: Session, sid: str | None) -> UserSession | None:
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
row = db.get(UserSession, sid)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
now = _utcnow()
|
||||||
|
expires = row.expires_at if row.expires_at.tzinfo else row.expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if expires <= now:
|
||||||
|
return None
|
||||||
|
row.last_seen_at = now
|
||||||
|
if not row.remember:
|
||||||
|
row.expires_at = now + SLIDING_TTL
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(db: Session, sid: str) -> None:
|
||||||
|
db.execute(delete(UserSession).where(UserSession.id == sid))
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all_for_user(db: Session, user_id: int) -> int:
|
||||||
|
res = db.execute(delete(UserSession).where(UserSession.user_id == user_id))
|
||||||
|
return res.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired(db: Session) -> int:
|
||||||
|
res = db.execute(delete(UserSession).where(UserSession.expires_at <= _utcnow()))
|
||||||
|
return res.rowcount or 0
|
||||||
152
containers/clearview/src/clearview_app/auth/users_router.py
Normal file
152
containers/clearview/src/clearview_app/auth/users_router.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"""Admin endpoints: user CRUD, password reset, audit log."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from . import sessions as S
|
||||||
|
from .audit import record_event
|
||||||
|
from .dependencies import get_db, require_admin
|
||||||
|
from .models import AuthAudit, User
|
||||||
|
from .schemas import (
|
||||||
|
AuditItem,
|
||||||
|
CreateUserRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
|
UserItem,
|
||||||
|
)
|
||||||
|
from .security import PasswordPolicyError, hash_password, validate_password
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _ip(request: Request) -> str | None:
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_item(u: User) -> UserItem:
|
||||||
|
return UserItem(
|
||||||
|
id=u.id, username=u.username, role=u.role, is_active=u.is_active, created_at=u.created_at # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/users", response_model=list[UserItem])
|
||||||
|
def list_users(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: Annotated[User, Depends(require_admin)],
|
||||||
|
) -> list[UserItem]:
|
||||||
|
rows = db.execute(select(User).order_by(User.created_at.asc())).scalars().all()
|
||||||
|
return [_to_item(u) for u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users", response_model=UserItem)
|
||||||
|
def create_user(
|
||||||
|
payload: CreateUserRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
actor: Annotated[User, Depends(require_admin)],
|
||||||
|
) -> UserItem:
|
||||||
|
try:
|
||||||
|
validate_password(payload.password)
|
||||||
|
except PasswordPolicyError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if db.execute(select(User).where(User.username == payload.username)).scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists")
|
||||||
|
u = User(
|
||||||
|
username=payload.username,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role=payload.role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(u); db.flush()
|
||||||
|
record_event(
|
||||||
|
db, event="user_create", user_id=actor.id, ip=_ip(request),
|
||||||
|
detail={"target": u.id, "username": u.username, "role": u.role},
|
||||||
|
)
|
||||||
|
db.commit(); db.refresh(u)
|
||||||
|
return _to_item(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/users/{user_id}", response_model=UserItem)
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
payload: UpdateUserRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
actor: Annotated[User, Depends(require_admin)],
|
||||||
|
) -> UserItem:
|
||||||
|
u = db.get(User, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
changed: dict = {}
|
||||||
|
if payload.role is not None and payload.role != u.role:
|
||||||
|
u.role = payload.role; changed["role"] = payload.role
|
||||||
|
if payload.is_active is not None and payload.is_active != u.is_active:
|
||||||
|
u.is_active = payload.is_active; changed["is_active"] = payload.is_active
|
||||||
|
if not payload.is_active:
|
||||||
|
S.revoke_all_for_user(db, u.id)
|
||||||
|
if changed:
|
||||||
|
record_event(db, event="user_update", user_id=actor.id, ip=_ip(request), detail={"target": u.id, **changed})
|
||||||
|
db.commit(); db.refresh(u)
|
||||||
|
return _to_item(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/users/{user_id}")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
actor: Annotated[User, Depends(require_admin)],
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
if user_id == actor.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||||
|
u = db.get(User, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
db.delete(u)
|
||||||
|
record_event(db, event="user_delete", user_id=actor.id, ip=_ip(request), detail={"target": user_id})
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users/{user_id}/reset-password")
|
||||||
|
def reset_password(
|
||||||
|
user_id: int,
|
||||||
|
payload: ResetPasswordRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
actor: Annotated[User, Depends(require_admin)],
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
u = db.get(User, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
try:
|
||||||
|
validate_password(payload.password)
|
||||||
|
except PasswordPolicyError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
u.password_hash = hash_password(payload.password)
|
||||||
|
S.revoke_all_for_user(db, u.id)
|
||||||
|
record_event(db, event="password_reset", user_id=actor.id, ip=_ip(request), detail={"target": u.id})
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/audit", response_model=list[AuditItem])
|
||||||
|
def list_audit(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: Annotated[User, Depends(require_admin)],
|
||||||
|
limit: int = 200,
|
||||||
|
event: str | None = None,
|
||||||
|
) -> list[AuditItem]:
|
||||||
|
limit = max(1, min(limit, 1000))
|
||||||
|
q = select(AuthAudit).order_by(AuthAudit.ts.desc()).limit(limit)
|
||||||
|
if event:
|
||||||
|
q = q.where(AuthAudit.event == event)
|
||||||
|
rows = db.execute(q).scalars().all()
|
||||||
|
return [
|
||||||
|
AuditItem(id=r.id, ts=r.ts, user_id=r.user_id, event=r.event, ip=r.ip, detail=r.detail)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
@ -36,3 +36,8 @@ SCAN_HTTP_BACKOFF_SEC = _int_env("SCAN_HTTP_BACKOFF_SEC", 2)
|
|||||||
|
|
||||||
SCAN_LIST_PAGE_SIZE = _int_env("SCAN_LIST_PAGE_SIZE", 200)
|
SCAN_LIST_PAGE_SIZE = _int_env("SCAN_LIST_PAGE_SIZE", 200)
|
||||||
SCAN_MAX_ITEMS_PER_LIST = _int_env("SCAN_MAX_ITEMS_PER_LIST", 10000)
|
SCAN_MAX_ITEMS_PER_LIST = _int_env("SCAN_MAX_ITEMS_PER_LIST", 10000)
|
||||||
|
|
||||||
|
# Auth cookie settings (override via env)
|
||||||
|
COOKIE_NAME = "clearview_session"
|
||||||
|
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
|
||||||
|
COOKIE_SAMESITE = "lax"
|
||||||
|
|||||||
@ -8,13 +8,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .api_jobs import router as jobs_router
|
from .api_jobs import router as jobs_router
|
||||||
from .api_onboarding import router as onboarding_router
|
from .api_onboarding import router as onboarding_router
|
||||||
from .api_tenants import router as tenants_router
|
from .api_tenants import router as tenants_router
|
||||||
|
from .auth.dependencies import require_user
|
||||||
|
from .auth.router import router as auth_router
|
||||||
|
from .auth.users_router import router as users_router
|
||||||
from .db_migrate import run_migrations
|
from .db_migrate import run_migrations
|
||||||
from .version import display_version
|
from .version import display_version
|
||||||
from .worker import ScanWorker
|
from .worker import ScanWorker
|
||||||
@ -47,9 +50,17 @@ def version() -> dict[str, str]:
|
|||||||
return {"version": display_version()}
|
return {"version": display_version()}
|
||||||
|
|
||||||
|
|
||||||
app.include_router(tenants_router)
|
# Public auth endpoints (login / setup / setup-required) — no dependency.
|
||||||
app.include_router(jobs_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(onboarding_router)
|
|
||||||
|
# Admin endpoints — already enforce require_admin internally.
|
||||||
|
app.include_router(users_router)
|
||||||
|
|
||||||
|
# Existing routers gated by an authenticated session.
|
||||||
|
_protected = [Depends(require_user)]
|
||||||
|
app.include_router(tenants_router, dependencies=_protected)
|
||||||
|
app.include_router(jobs_router, dependencies=_protected)
|
||||||
|
app.include_router(onboarding_router, dependencies=_protected)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
"""Create users, user_sessions, auth_audit tables.
|
||||||
|
|
||||||
|
Revision ID: 0003_auth_tables
|
||||||
|
Revises: 0002_timestamptz
|
||||||
|
Create Date: 2026-05-28
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0003_auth_tables"
|
||||||
|
down_revision = "0002_timestamptz"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("username", sa.String(length=128), nullable=False, unique=True),
|
||||||
|
sa.Column("password_hash", sa.Text(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_sessions",
|
||||||
|
sa.Column("id", sa.String(length=64), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||||
|
sa.Column("remember", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"])
|
||||||
|
op.create_index("ix_user_sessions_expires_at", "user_sessions", ["expires_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"auth_audit",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("ts", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("event", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("detail", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_auth_audit_ts", "auth_audit", ["ts"])
|
||||||
|
op.create_index("ix_auth_audit_event", "auth_audit", ["event"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_auth_audit_event", table_name="auth_audit")
|
||||||
|
op.drop_index("ix_auth_audit_ts", table_name="auth_audit")
|
||||||
|
op.drop_table("auth_audit")
|
||||||
|
op.drop_index("ix_user_sessions_expires_at", table_name="user_sessions")
|
||||||
|
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
|
||||||
|
op.drop_table("user_sessions")
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
@ -7,7 +7,7 @@ history, so operators can see exactly which image build is running.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
VERSION = "v0.1.0"
|
VERSION = "v0.1.0"
|
||||||
BUILD = 2
|
BUILD = 3
|
||||||
|
|
||||||
|
|
||||||
def display_version() -> str:
|
def display_version() -> str:
|
||||||
|
|||||||
@ -14,10 +14,13 @@ from .config import (
|
|||||||
SCAN_TARGET_MAX_RETRIES,
|
SCAN_TARGET_MAX_RETRIES,
|
||||||
SCAN_TARGET_TIMEOUT_SEC,
|
SCAN_TARGET_TIMEOUT_SEC,
|
||||||
)
|
)
|
||||||
|
from .auth.sessions import purge_expired
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal
|
||||||
from .models import PermissionDeviation, ScanJob, ScanTarget, TenantProfile
|
from .models import PermissionDeviation, ScanJob, ScanTarget, TenantProfile
|
||||||
from .scanners import AuthConfig, ProbeResult, probe, scan
|
from .scanners import AuthConfig, ProbeResult, probe, scan
|
||||||
|
|
||||||
|
_SESSION_PURGE_INTERVAL_SEC = 300
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -25,6 +28,7 @@ class ScanWorker:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
|
self._last_session_purge: float = 0.0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._thread and self._thread.is_alive():
|
if self._thread and self._thread.is_alive():
|
||||||
@ -41,10 +45,25 @@ class ScanWorker:
|
|||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
|
self._maybe_purge_sessions()
|
||||||
did_work = self._process_next_job()
|
did_work = self._process_next_job()
|
||||||
if not did_work:
|
if not did_work:
|
||||||
self._stop_event.wait(SCAN_JOB_POLL_INTERVAL_SEC)
|
self._stop_event.wait(SCAN_JOB_POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
|
def _maybe_purge_sessions(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_session_purge < _SESSION_PURGE_INTERVAL_SEC:
|
||||||
|
return
|
||||||
|
self._last_session_purge = now
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
removed = purge_expired(db)
|
||||||
|
db.commit()
|
||||||
|
if removed:
|
||||||
|
log.info("purged %d expired auth sessions", removed)
|
||||||
|
except Exception:
|
||||||
|
log.exception("auth session purge failed")
|
||||||
|
|
||||||
def _process_next_job(self) -> bool:
|
def _process_next_job(self) -> bool:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
# Atomic claim: lock the chosen queued row and skip rows already
|
# Atomic claim: lock the chosen queued row and skip rows already
|
||||||
|
|||||||
0
containers/clearview/tests/__init__.py
Normal file
0
containers/clearview/tests/__init__.py
Normal file
53
containers/clearview/tests/conftest.py
Normal file
53
containers/clearview/tests/conftest.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""Pytest fixtures for Clearview tests.
|
||||||
|
|
||||||
|
Uses an in-memory SQLite database. Schema is created from the SQLAlchemy
|
||||||
|
metadata directly (the Alembic migrations target Postgres types like JSONB).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
SRC = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("COOKIE_SECURE", "false")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_engine():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _fk_on(dbapi_conn, _):
|
||||||
|
cur = dbapi_conn.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
from clearview_app.auth.models import Base as AuthBase
|
||||||
|
|
||||||
|
AuthBase.metadata.create_all(engine)
|
||||||
|
yield engine
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_session(db_engine):
|
||||||
|
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
76
containers/clearview/tests/test_auth_router.py
Normal file
76
containers/clearview/tests/test_auth_router.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from clearview_app.auth.dependencies import get_db
|
||||||
|
from clearview_app.auth.router import router as auth_router
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(db_engine):
|
||||||
|
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_required_when_empty(db_engine):
|
||||||
|
c = TestClient(make_app(db_engine))
|
||||||
|
assert c.get("/api/auth/setup-required").json() == {"setup_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_creates_first_admin_and_logs_in(db_engine):
|
||||||
|
c = TestClient(make_app(db_engine))
|
||||||
|
r = c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.cookies.get("clearview_session")
|
||||||
|
me = c.get("/api/auth/me")
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json() == {"username": "root", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_when_users_exist(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
r = TestClient(app).post("/api/auth/setup", json={"username": "x", "password": "CorrectHorse42"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_returns_401(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
r = TestClient(app).post("/api/auth/login", json={"username": "root", "password": "WrongPass00X", "remember": False})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success_sets_cookie(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
TestClient(app).post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
c2 = TestClient(app)
|
||||||
|
r = c2.post("/api/auth/login", json={"username": "root", "password": "CorrectHorse42", "remember": True})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert c2.cookies.get("clearview_session")
|
||||||
|
assert c2.get("/api/auth/me").json()["username"] == "root"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_invalidates_session(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
c = TestClient(app)
|
||||||
|
c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
assert c.get("/api/auth/me").status_code == 200
|
||||||
|
c.post("/api/auth/logout")
|
||||||
|
assert c.get("/api/auth/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_policy_enforced_on_setup(db_engine):
|
||||||
|
c = TestClient(make_app(db_engine))
|
||||||
|
r = c.post("/api/auth/setup", json={"username": "root", "password": "short"})
|
||||||
|
assert r.status_code == 400
|
||||||
91
containers/clearview/tests/test_dependencies.py
Normal file
91
containers/clearview/tests/test_dependencies.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from clearview_app.auth import sessions as S
|
||||||
|
from clearview_app.auth.dependencies import (
|
||||||
|
AuthedUser,
|
||||||
|
get_db,
|
||||||
|
require_admin,
|
||||||
|
require_user,
|
||||||
|
)
|
||||||
|
from clearview_app.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app_and_client(db_engine):
|
||||||
|
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/who")
|
||||||
|
def who(u: AuthedUser = Depends(require_user)):
|
||||||
|
return {"id": u.id, "role": u.role}
|
||||||
|
|
||||||
|
@app.get("/admin-only")
|
||||||
|
def admin_only(u: AuthedUser = Depends(require_admin)):
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
return app, Session
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(Session, role: str, username: str = "x"):
|
||||||
|
s = Session()
|
||||||
|
u = User(username=username, password_hash="h", role=role)
|
||||||
|
s.add(u); s.commit(); s.refresh(u); s.close()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def _login(Session, user_id: int) -> str:
|
||||||
|
s = Session()
|
||||||
|
sid, _ = S.create_session(s, user_id=user_id, remember=False, ip=None, user_agent=None)
|
||||||
|
s.commit(); s.close()
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def test_anon_gets_401(app_and_client):
|
||||||
|
app, _ = app_and_client
|
||||||
|
assert TestClient(app).get("/who").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_can_access_require_user(app_and_client):
|
||||||
|
app, Session = app_and_client
|
||||||
|
u = _make_user(Session, "user")
|
||||||
|
sid = _login(Session, u.id)
|
||||||
|
c = TestClient(app); c.cookies.set("clearview_session", sid)
|
||||||
|
r = c.get("/who")
|
||||||
|
assert r.status_code == 200 and r.json()["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_blocked_from_admin(app_and_client):
|
||||||
|
app, Session = app_and_client
|
||||||
|
u = _make_user(Session, "user")
|
||||||
|
sid = _login(Session, u.id)
|
||||||
|
c = TestClient(app); c.cookies.set("clearview_session", sid)
|
||||||
|
assert c.get("/admin-only").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_allowed(app_and_client):
|
||||||
|
app, Session = app_and_client
|
||||||
|
u = _make_user(Session, "admin")
|
||||||
|
sid = _login(Session, u.id)
|
||||||
|
c = TestClient(app); c.cookies.set("clearview_session", sid)
|
||||||
|
assert c.get("/admin-only").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_user_rejected(app_and_client):
|
||||||
|
app, Session = app_and_client
|
||||||
|
u = _make_user(Session, "admin")
|
||||||
|
s = Session(); s.get(User, u.id).is_active = False; s.commit(); s.close()
|
||||||
|
sid = _login(Session, u.id)
|
||||||
|
c = TestClient(app); c.cookies.set("clearview_session", sid)
|
||||||
|
assert c.get("/who").status_code == 401
|
||||||
24
containers/clearview/tests/test_existing_routes_protected.py
Normal file
24
containers/clearview/tests/test_existing_routes_protected.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Smoke check that existing routers refuse anonymous requests once gated."""
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from clearview_app.api_tenants import router as tenants_router
|
||||||
|
from clearview_app.auth.dependencies import get_db, require_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenants_route_requires_auth(db_engine):
|
||||||
|
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(tenants_router, dependencies=[Depends(require_user)])
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
assert TestClient(app).get("/api/tenants").status_code == 401
|
||||||
44
containers/clearview/tests/test_models.py
Normal file
44
containers/clearview/tests/test_models.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from clearview_app.auth.models import AuthAudit, User, UserSession
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_defaults(db_session):
|
||||||
|
u = User(username="alice", password_hash="x", role="admin")
|
||||||
|
db_session.add(u); db_session.commit(); db_session.refresh(u)
|
||||||
|
assert u.id is not None
|
||||||
|
assert u.is_active is True
|
||||||
|
assert isinstance(u.created_at, datetime)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_persists_with_expiry(db_session):
|
||||||
|
u = User(username="bob", password_hash="x", role="user")
|
||||||
|
db_session.add(u); db_session.commit(); db_session.refresh(u)
|
||||||
|
|
||||||
|
s = UserSession(
|
||||||
|
id="abc123",
|
||||||
|
user_id=u.id,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||||
|
ip="1.2.3.4",
|
||||||
|
user_agent="ua",
|
||||||
|
remember=False,
|
||||||
|
)
|
||||||
|
db_session.add(s); db_session.commit()
|
||||||
|
assert s.created_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_row(db_session):
|
||||||
|
a = AuthAudit(event="login_ok", ip="9.9.9.9", detail={"k": "v"})
|
||||||
|
db_session.add(a); db_session.commit()
|
||||||
|
assert a.id is not None
|
||||||
|
assert a.detail == {"k": "v"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_event_persists(db_session):
|
||||||
|
from clearview_app.auth.audit import record_event
|
||||||
|
record_event(db_session, event="login_ok", user_id=None, ip="1.1.1.1", detail={"u": "x"})
|
||||||
|
db_session.commit()
|
||||||
|
rows = db_session.query(AuthAudit).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].event == "login_ok"
|
||||||
|
assert rows[0].detail == {"u": "x"}
|
||||||
37
containers/clearview/tests/test_security.py
Normal file
37
containers/clearview/tests/test_security.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from clearview_app.auth.security import (
|
||||||
|
PasswordPolicyError,
|
||||||
|
hash_password,
|
||||||
|
new_session_id,
|
||||||
|
validate_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_and_verify_roundtrip():
|
||||||
|
h = hash_password("CorrectHorse42")
|
||||||
|
assert verify_password("CorrectHorse42", h) is True
|
||||||
|
assert verify_password("wrong", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_returns_false_on_garbage_hash():
|
||||||
|
assert verify_password("anything", "not-a-real-hash") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pw", ["short1A", "alllowercase", "ALLUPPERCASE", "12345678901234"])
|
||||||
|
def test_policy_rejects(pw):
|
||||||
|
with pytest.raises(PasswordPolicyError):
|
||||||
|
validate_password(pw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pw", ["CorrectHorse42", "abcdefghij12"])
|
||||||
|
def test_policy_accepts(pw):
|
||||||
|
validate_password(pw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_session_id_unique_and_hex():
|
||||||
|
a = new_session_id()
|
||||||
|
b = new_session_id()
|
||||||
|
assert a != b
|
||||||
|
assert len(a) == 32 and all(c in "0123456789abcdef" for c in a)
|
||||||
79
containers/clearview/tests/test_sessions.py
Normal file
79
containers/clearview/tests/test_sessions.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from clearview_app.auth import sessions as S
|
||||||
|
from clearview_app.auth.models import User, UserSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def user(db_session):
|
||||||
|
u = User(username="alice", password_hash="x", role="admin")
|
||||||
|
db_session.add(u); db_session.commit(); db_session.refresh(u)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_sliding(db_session, user):
|
||||||
|
sid, expires = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
|
||||||
|
db_session.commit()
|
||||||
|
assert len(sid) == 32
|
||||||
|
row = db_session.get(UserSession, sid)
|
||||||
|
assert row.remember is False
|
||||||
|
delta = row.expires_at - datetime.now(timezone.utc)
|
||||||
|
assert timedelta(hours=7, minutes=55) < delta < timedelta(hours=8, minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_remember(db_session, user):
|
||||||
|
sid, _ = S.create_session(db_session, user_id=user.id, remember=True, ip=None, user_agent=None)
|
||||||
|
db_session.commit()
|
||||||
|
row = db_session.get(UserSession, sid)
|
||||||
|
delta = row.expires_at - datetime.now(timezone.utc)
|
||||||
|
assert delta > timedelta(days=29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_refresh_sliding_extends(db_session, user):
|
||||||
|
sid, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
|
||||||
|
db_session.commit()
|
||||||
|
row = db_session.get(UserSession, sid)
|
||||||
|
row.expires_at = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
|
db_session.commit()
|
||||||
|
looked = S.lookup_and_refresh(db_session, sid)
|
||||||
|
db_session.commit()
|
||||||
|
assert looked is not None
|
||||||
|
assert looked.expires_at - datetime.now(timezone.utc) > timedelta(hours=7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_refresh_remember_does_not_slide(db_session, user):
|
||||||
|
sid, _ = S.create_session(db_session, user_id=user.id, remember=True, ip=None, user_agent=None)
|
||||||
|
db_session.commit()
|
||||||
|
before = db_session.get(UserSession, sid).expires_at
|
||||||
|
S.lookup_and_refresh(db_session, sid)
|
||||||
|
db_session.commit()
|
||||||
|
after = db_session.get(UserSession, sid).expires_at
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_session_returns_none(db_session, user):
|
||||||
|
sid, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
|
||||||
|
row = db_session.get(UserSession, sid)
|
||||||
|
row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
|
db_session.commit()
|
||||||
|
assert S.lookup_and_refresh(db_session, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke(db_session, user):
|
||||||
|
sid, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
|
||||||
|
db_session.commit()
|
||||||
|
S.revoke(db_session, sid); db_session.commit()
|
||||||
|
assert db_session.get(UserSession, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_expired(db_session, user):
|
||||||
|
fresh, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
|
||||||
|
stale, _ = S.create_session(db_session, user_id=user.id, remember=False, ip=None, user_agent=None)
|
||||||
|
db_session.get(UserSession, stale).expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||||
|
db_session.commit()
|
||||||
|
removed = S.purge_expired(db_session); db_session.commit()
|
||||||
|
assert removed == 1
|
||||||
|
assert db_session.get(UserSession, fresh) is not None
|
||||||
|
assert db_session.get(UserSession, stale) is None
|
||||||
74
containers/clearview/tests/test_users_router.py
Normal file
74
containers/clearview/tests/test_users_router.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from clearview_app.auth.dependencies import get_db
|
||||||
|
from clearview_app.auth.router import router as auth_router
|
||||||
|
from clearview_app.auth.users_router import router as users_router
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(db_engine):
|
||||||
|
Session = sessionmaker(bind=db_engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
s = Session()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(users_router)
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_admin(app):
|
||||||
|
c = TestClient(app)
|
||||||
|
c.post("/api/auth/setup", json={"username": "root", "password": "CorrectHorse42"})
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_blocked(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
admin = _bootstrap_admin(app)
|
||||||
|
admin.post("/api/users", json={"username": "joe", "password": "JoePassword42", "role": "user"})
|
||||||
|
user = TestClient(app)
|
||||||
|
user.post("/api/auth/login", json={"username": "joe", "password": "JoePassword42", "remember": False})
|
||||||
|
assert user.get("/api/users").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_list_update_delete(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
c = _bootstrap_admin(app)
|
||||||
|
|
||||||
|
r = c.post("/api/users", json={"username": "alice", "password": "AlicePassword42", "role": "user"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
uid = r.json()["id"]
|
||||||
|
|
||||||
|
assert {u["username"] for u in c.get("/api/users").json()} == {"root", "alice"}
|
||||||
|
|
||||||
|
c.patch(f"/api/users/{uid}", json={"role": "admin"})
|
||||||
|
assert next(u for u in c.get("/api/users").json() if u["id"] == uid)["role"] == "admin"
|
||||||
|
|
||||||
|
c.post(f"/api/users/{uid}/reset-password", json={"password": "NewAlicePass42"})
|
||||||
|
fresh = TestClient(app)
|
||||||
|
assert fresh.post("/api/auth/login", json={"username": "alice", "password": "NewAlicePass42", "remember": False}).status_code == 200
|
||||||
|
|
||||||
|
c.delete(f"/api/users/{uid}")
|
||||||
|
assert {u["username"] for u in c.get("/api/users").json()} == {"root"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_delete_self(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
c = _bootstrap_admin(app)
|
||||||
|
me_id = c.get("/api/users").json()[0]["id"]
|
||||||
|
assert c.delete(f"/api/users/{me_id}").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_returns_rows(db_engine):
|
||||||
|
app = make_app(db_engine)
|
||||||
|
c = _bootstrap_admin(app)
|
||||||
|
rows = c.get("/api/audit").json()
|
||||||
|
assert any(r["event"] == "setup" for r in rows)
|
||||||
@ -2,6 +2,108 @@
|
|||||||
|
|
||||||
This file documents changes on the develop branch of this project.
|
This file documents changes on the develop branch of this project.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: feature complete
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- End-to-end session-based authentication with admin/user roles, initial-setup flow, user management UI, and audit log.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- All scanning, tenant, and onboarding endpoints now require an authenticated session.
|
||||||
|
- Build bumped to `v0.1.0.3`.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: session purge
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Worker periodically deletes expired rows from `user_sessions` (every 5 minutes).
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: Users + Audit UI
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Users admin view with create/edit/delete and password reset.
|
||||||
|
- Audit log view (latest 100 events).
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: SPA gate
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- App boot calls `/api/auth/me`; 401 redirects to `/login.html` (or `/setup.html`).
|
||||||
|
- Header user-badge with sign-out button.
|
||||||
|
- Users nav link (hidden for non-admin).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `requestJson` wrapper now redirects on any 401 response.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: setup page
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `setup.html` for first-run admin creation, reachable only while the `users` table is empty.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: login page
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `login.html` with username / password / remember-me.
|
||||||
|
- Shared `auth.js` helpers (postJson, getJson).
|
||||||
|
- CSS for auth pages and the header user-badge.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: API gating
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tenants, Jobs, and Onboarding routers now require an authenticated session.
|
||||||
|
- Auth and Users routers wired into the FastAPI app.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: users + audit endpoints
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Admin endpoints: list/create/update/delete users, reset password, view audit log.
|
||||||
|
- Self-delete protection; deactivating or resetting password revokes existing sessions.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: auth router
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `/api/auth/setup-required`, `/api/auth/setup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`.
|
||||||
|
- HttpOnly session cookie with SameSite=Lax; Secure flag controlled by `COOKIE_SECURE` env.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: FastAPI dependencies
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `require_user` / `require_admin` cookie-based session loading.
|
||||||
|
- 401 for missing/expired/inactive; 403 for non-admin on admin routes.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: session lifecycle
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `auth.sessions` with 8h sliding / 30d remember TTLs, lookup-and-refresh, revoke, purge.
|
||||||
|
- `UTCDateTime` type decorator in `auth.models` to keep UTC-aware datetimes across SQLite roundtrips.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: audit helper
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `auth.audit.record_event()` for one-line writes to `auth_audit`.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: database migration
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Alembic migration `0003_auth_tables` creating `users`, `user_sessions`, `auth_audit`.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: hashing + password policy
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Argon2id password hashing (`hash_password`, `verify_password`).
|
||||||
|
- Server-side password policy (min 12, letter + digit).
|
||||||
|
- Opaque hex session-id generator.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: data models
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `User`, `UserSession`, `AuthAudit` SQLAlchemy models.
|
||||||
|
- Model-level tests using SQLite in-memory engine.
|
||||||
|
|
||||||
|
## 2026-05-28 — Authentication: scaffold
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `argon2-cffi`, `pytest`, `httpx` dependencies.
|
||||||
|
- New `clearview_app/auth/` package skeleton.
|
||||||
|
- `tests/` directory with SQLite-backed pytest fixtures.
|
||||||
|
|
||||||
## 2026-05-26 — UI/UX: dead CSS removal, a11y, distinct risk colours, richer dashboard
|
## 2026-05-26 — UI/UX: dead CSS removal, a11y, distinct risk colours, richer dashboard
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
2423
docs/superpowers/plans/2026-05-28-authentication.md
Normal file
2423
docs/superpowers/plans/2026-05-28-authentication.md
Normal file
File diff suppressed because it is too large
Load Diff
177
docs/superpowers/specs/2026-05-28-authentication-design.md
Normal file
177
docs/superpowers/specs/2026-05-28-authentication-design.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Clearview — Authentication Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-28
|
||||||
|
**Status:** Approved (brainstorming phase)
|
||||||
|
**Scope:** Add an authentication layer to the existing Clearview FastAPI + static-frontend application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Restrict access to the Clearview UI and API to a small group of named administrators. No self-service registration, no public exposure of endpoints, no multi-tenant user isolation.
|
||||||
|
|
||||||
|
## 2. Requirements
|
||||||
|
|
||||||
|
| # | Requirement |
|
||||||
|
|---|---|
|
||||||
|
| R1 | All existing API routes (`/api/tenants/*`, `/api/jobs/*`, `/api/onboarding/*`) require an authenticated session. |
|
||||||
|
| R2 | Two roles: `admin` and `user`. Only `admin` can manage users and view the audit log. Both can use the scanner UI. |
|
||||||
|
| R3 | Server-side sessions using an opaque session ID stored in an HttpOnly cookie. |
|
||||||
|
| R4 | First admin is created via a one-time **initial-setup** page that is reachable only while the `users` table is empty. No env-var fallback. |
|
||||||
|
| R5 | Password hashing with **Argon2id** (default parameters via `argon2-cffi`). |
|
||||||
|
| R6 | Password policy: minimum 12 characters, at least one letter and one digit. Validated server-side. |
|
||||||
|
| R7 | Session TTL: **8 hours sliding** by default; **30 days fixed** when "remember me" is checked at login. |
|
||||||
|
| R8 | Audit log persisted in DB and viewable in the UI by admins. |
|
||||||
|
| R9 | Existing changelog convention applies: append entries to `changelog-develop.md` per change. |
|
||||||
|
|
||||||
|
Explicitly **out of scope** for v1: rate limiting / login lockout, password reset via email, MFA, SSO, fine-grained permissions beyond `admin` / `user`.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Backend module layout
|
||||||
|
|
||||||
|
A new package `clearview_app/auth/` with focused modules:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `auth/__init__.py` | Package marker. |
|
||||||
|
| `auth/models.py` | SQLAlchemy models: `User`, `Session`, `AuthAudit`. |
|
||||||
|
| `auth/security.py` | Argon2id hashing, password policy validation, session-ID generation. |
|
||||||
|
| `auth/sessions.py` | Create / lookup / refresh / revoke session records; expiry handling. |
|
||||||
|
| `auth/audit.py` | Single `record(event, user_id, ip, detail)` helper. |
|
||||||
|
| `auth/dependencies.py` | FastAPI dependencies: `current_session`, `require_user`, `require_admin`. |
|
||||||
|
| `auth/router.py` | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `GET /api/auth/setup-required`, `POST /api/auth/setup`. |
|
||||||
|
| `auth/users_router.py` | Admin-only: `GET/POST/PATCH/DELETE /api/users`, `POST /api/users/{id}/reset-password`, `GET /api/audit`. |
|
||||||
|
|
||||||
|
`main.py` wires the new routers **before** the static-files mount and applies `Depends(require_user)` to the three existing routers (`tenants`, `jobs`, `onboarding`).
|
||||||
|
|
||||||
|
### 3.2 Database
|
||||||
|
|
||||||
|
Three new tables, added via a new Alembic migration in `clearview_app/migrations/versions/`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
users
|
||||||
|
id int pk
|
||||||
|
username text unique not null
|
||||||
|
password_hash text not null -- Argon2id encoded string
|
||||||
|
role text not null -- 'admin' | 'user'
|
||||||
|
is_active bool not null default true
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
|
||||||
|
sessions
|
||||||
|
id uuid pk -- opaque session id, stored in cookie
|
||||||
|
user_id int not null fk -> users.id on delete cascade
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
expires_at timestamptz not null
|
||||||
|
last_seen_at timestamptz not null default now()
|
||||||
|
ip text
|
||||||
|
user_agent text
|
||||||
|
remember bool not null default false
|
||||||
|
|
||||||
|
auth_audit
|
||||||
|
id bigserial pk
|
||||||
|
ts timestamptz not null default now()
|
||||||
|
user_id int null fk -> users.id on delete set null
|
||||||
|
event text not null -- see event list below
|
||||||
|
ip text
|
||||||
|
detail jsonb -- e.g. {"username": "alice"} on login_fail
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit events: `login_ok`, `login_fail`, `logout`, `user_create`, `user_update`, `user_delete`, `password_reset`, `setup`.
|
||||||
|
|
||||||
|
### 3.3 Session handling
|
||||||
|
|
||||||
|
- Cookie name: `clearview_session`. Flags: `HttpOnly`, `SameSite=Lax`, `Secure` (set when request is HTTPS; configurable for local-dev HTTP).
|
||||||
|
- Cookie value: the `sessions.id` UUIDv4. No data is encoded in the cookie itself.
|
||||||
|
- On every authenticated request:
|
||||||
|
1. Read cookie → look up row in `sessions`.
|
||||||
|
2. If missing or `expires_at <= now()` → 401, delete cookie.
|
||||||
|
3. If `remember = false` → extend `expires_at = now() + 8h` (sliding).
|
||||||
|
4. If `remember = true` → leave `expires_at` untouched (fixed 30 days from creation).
|
||||||
|
5. Update `last_seen_at`.
|
||||||
|
- Logout: delete the session row and clear the cookie.
|
||||||
|
- Cleanup: a lightweight purge of expired sessions runs at login time and is also exposed as a periodic task in the existing worker (cheap query: `DELETE FROM sessions WHERE expires_at < now()`).
|
||||||
|
|
||||||
|
### 3.4 Initial-setup flow
|
||||||
|
|
||||||
|
- `GET /api/auth/setup-required` returns `{"setup_required": true}` iff `COUNT(*) FROM users = 0`.
|
||||||
|
- `POST /api/auth/setup` accepts `{username, password}`, succeeds **only** when the table is empty, creates the user with `role='admin'`, immediately establishes a session (sets cookie), and writes a `setup` audit row.
|
||||||
|
- Once any user exists, both endpoints return 409 / `setup_required=false`.
|
||||||
|
|
||||||
|
### 3.5 Password policy
|
||||||
|
|
||||||
|
- Server-side check before hashing or updating: `len(pw) >= 12 and any(c.isalpha() for c in pw) and any(c.isdigit() for c in pw)`.
|
||||||
|
- Hashing: `argon2.PasswordHasher()` with library defaults; the encoded string includes salt + parameters, so future tuning is non-breaking.
|
||||||
|
|
||||||
|
## 4. Frontend
|
||||||
|
|
||||||
|
The frontend is the existing static `site/` (`index.html`, `app.js`, `styles.css`). Changes:
|
||||||
|
|
||||||
|
- **New `login.html`** — standalone page, no app-shell. Form with username, password, "remember me" checkbox. Posts to `/api/auth/login`, then redirects to `/`.
|
||||||
|
- **New `setup.html`** — same shell as login, used when `/api/auth/setup-required` returns true. Posts to `/api/auth/setup`.
|
||||||
|
- **`app.js`**:
|
||||||
|
- On boot, call `GET /api/auth/me`. On 401, redirect to `/login` (or `/setup` if `setup-required`).
|
||||||
|
- Wrap the `fetch` helper so any 401 response triggers a redirect to `/login`.
|
||||||
|
- Header shows `username (role)` + a **Logout** button that calls `POST /api/auth/logout` then redirects to `/login`.
|
||||||
|
- **New "Users" sidebar tab** (admin-only): list / add / edit (username, role, active) / delete users, and a **Reset password** action that opens a modal.
|
||||||
|
- **New "Audit" sub-view under Users** (admin-only): paged table of `auth_audit` rows, newest first, with event-type filter.
|
||||||
|
- Non-admin users do not see the Users tab; the backend also enforces this independently (defence in depth).
|
||||||
|
|
||||||
|
## 5. Data flow — successful login
|
||||||
|
|
||||||
|
1. Browser `POST /api/auth/login` with `{username, password, remember}`.
|
||||||
|
2. Server fetches user by username. If not found or `is_active=false` → 401 + audit `login_fail` with the supplied username.
|
||||||
|
3. Verify password with Argon2id. On mismatch → 401 + audit `login_fail`.
|
||||||
|
4. Insert `sessions` row (`remember` flag determines TTL: 8h or 30d).
|
||||||
|
5. `Set-Cookie: clearview_session=<uuid>; HttpOnly; SameSite=Lax; Secure?; Path=/; Max-Age=<ttl>`.
|
||||||
|
6. Audit `login_ok`. Return `{username, role}`.
|
||||||
|
7. Browser then calls `GET /api/auth/me` and renders the app.
|
||||||
|
|
||||||
|
## 6. Error handling
|
||||||
|
|
||||||
|
| Situation | Response |
|
||||||
|
|---|---|
|
||||||
|
| Missing / invalid / expired session cookie on protected endpoint | `401 Unauthorized`, cookie cleared. |
|
||||||
|
| Authenticated `user` hitting an admin-only endpoint | `403 Forbidden`. |
|
||||||
|
| Setup endpoint called when users already exist | `409 Conflict`. |
|
||||||
|
| Password policy violation | `400 Bad Request` with a single human-readable message. |
|
||||||
|
| Argon2 verification raising any exception | Treated as failed login (no info leak). |
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
Pytest cases (added in the existing test layout, mirroring current patterns):
|
||||||
|
|
||||||
|
- Password hashing round-trip; policy validator edge cases.
|
||||||
|
- Login success, wrong password, unknown user, inactive user.
|
||||||
|
- Session expiry: sliding 8h refresh vs fixed 30d remember.
|
||||||
|
- Logout invalidates the session.
|
||||||
|
- `require_user` and `require_admin` reject correctly.
|
||||||
|
- Setup endpoint: succeeds when empty, 409 when not empty.
|
||||||
|
- User CRUD endpoints: admin allowed, `user` role gets 403.
|
||||||
|
- Audit rows are written for each event.
|
||||||
|
|
||||||
|
## 8. Migration & compatibility
|
||||||
|
|
||||||
|
- Single forward Alembic migration adds the three tables. No changes to existing tables.
|
||||||
|
- First deploy on an existing install: the `users` table is empty → users are redirected to `/setup` on first visit.
|
||||||
|
- No env vars are introduced for credentials. (An optional `CLEARVIEW_COOKIE_SECURE` toggle may be added so local-dev HTTP still works; default `true`.)
|
||||||
|
|
||||||
|
## 9. Work breakdown (units of work)
|
||||||
|
|
||||||
|
1. DB models + Alembic migration.
|
||||||
|
2. Auth core: hashing, policy, session create/lookup/refresh/revoke, audit helper.
|
||||||
|
3. Auth router: login / logout / me / setup-required / setup.
|
||||||
|
4. Users router + audit endpoint.
|
||||||
|
5. Apply `require_user` to existing routers; `require_admin` to user/audit routes.
|
||||||
|
6. Frontend: `login.html`, `setup.html`, fetch-wrapper 401 handling, header user-badge + logout.
|
||||||
|
7. Frontend: Users tab (CRUD + reset password) and Audit sub-view.
|
||||||
|
8. Tests for items 2–5.
|
||||||
|
9. Append entries to `changelog-develop.md` per change.
|
||||||
|
|
||||||
|
## 10. Open items deferred to v2
|
||||||
|
|
||||||
|
- Rate limiting / brute-force lockout.
|
||||||
|
- Email-based password reset.
|
||||||
|
- MFA / SSO via Microsoft Entra (would reuse existing tenant app-registration plumbing).
|
||||||
|
- Per-tenant data scoping for non-admin users.
|
||||||
Loading…
Reference in New Issue
Block a user