Отдельный префикс /api/v1 со своей авторификацией по API-ключам,
чтобы внешние системы могли забирать и менять данные, не получая
доступа к админке.
Что добавлено:
- таблица api_keys (db/init.sql, db/migration.sql, ensureApiKeysTable)
- CRUD ключей: GET/POST /api/api-keys, PUT/DELETE /:id, POST /:id/rotate
- requireApiKey: X-Api-Key или Authorization: Bearer, только для /api/v1/*
- 21 эндпоинт /api/v1: branches, groups, students, modules, entries,
lesson-reports, stats, me; списки в формате {items,total,limit,offset}
- страница управления ключами public/apikeys.html + пункт в меню
Безопасность:
- в БД только sha256(ключ) и префикс, секрет отдаётся один раз
- скоупы read/write: без write мутации дают 403
- branch_ids ключа сужают права и понижают роль до tutor
- per-key rate limit на cache.rateLimitStore, подбор ключей -> бан IP
- аудит мутаций с меткой via_api_key
- ключи не входят в бэкап и удаляются при restore
Проверено: api-keys.selftest.js (45 проверок), api.smoketest.js без
регрессий, работа без Redis через in-memory fallback.
146 lines
6.0 KiB
JavaScript
146 lines
6.0 KiB
JavaScript
let apiKeys = [];
|
|
let branches = [];
|
|
|
|
async function loadKeys() {
|
|
const [kRes, bRes] = await Promise.all([
|
|
fetch(`${API}/api/api-keys`, { headers: hdr() }),
|
|
fetch(`${API}/api/branches`, { headers: hdr() }),
|
|
]);
|
|
if (!kRes.ok) { showToast('Ошибка загрузки ключей'); return; }
|
|
if (bRes.ok) branches = await bRes.json();
|
|
apiKeys = await kRes.json();
|
|
renderKeys();
|
|
}
|
|
|
|
function selectedBranches() {
|
|
return [...document.getElementById('kBranches').selectedOptions].map(o => Number(o.value));
|
|
}
|
|
|
|
function keyStatus(k) {
|
|
if (k.revoked_at) return '<span class="status-off">Отозван</span>';
|
|
if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now()) return '<span class="status-off">Истёк</span>';
|
|
return '<span class="status-ok">Активен</span>';
|
|
}
|
|
|
|
function renderKeys() {
|
|
const tbody = document.querySelector('#keysTable tbody');
|
|
const empty = document.getElementById('keysEmpty');
|
|
tbody.innerHTML = '';
|
|
empty.style.display = apiKeys.length ? 'none' : 'block';
|
|
const branchById = {};
|
|
branches.forEach(b => { branchById[b.id] = b.name; });
|
|
apiKeys.forEach(k => {
|
|
const tr = document.createElement('tr');
|
|
const scopeNames = { read: 'чтение', write: 'запись' };
|
|
const scopes = (k.scopes || []).map(s => scopeNames[s] || s).join(', ');
|
|
const branchNames = (k.branch_ids || []).map(id => branchById[id] || `#${id}`).join(', ') || 'все';
|
|
const expires = k.expires_at ? fmtDateOnlyIso(k.expires_at) : 'бессрочный';
|
|
tr.innerHTML = `
|
|
<td>${k.id}</td>
|
|
<td>${esc(k.name)}</td>
|
|
<td class="key-prefix">${esc(k.prefix || '')}…</td>
|
|
<td class="key-scopes">${esc(scopes)}</td>
|
|
<td class="key-scopes">${esc(branchNames)}</td>
|
|
<td class="key-meta">${fmtDateTimeY(k.created_at)}</td>
|
|
<td class="key-meta">${k.last_used_at ? fmtDateTimeY(k.last_used_at) : '—'}</td>
|
|
<td>${keyStatus(k)}</td>
|
|
<td style="white-space:nowrap">
|
|
<button class="row-btn" data-rot="${k.id}" title="Перевыпустить ключ">⟳</button>
|
|
<button class="row-btn del" data-del="${k.id}" title="Удалить">×</button>
|
|
</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function openModal() {
|
|
const sel = document.getElementById('kBranches');
|
|
sel.innerHTML = branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
|
document.getElementById('kName').value = '';
|
|
document.getElementById('kRead').checked = true;
|
|
document.getElementById('kWrite').checked = false;
|
|
document.getElementById('kRate').value = '';
|
|
document.getElementById('kExpires').value = '';
|
|
document.getElementById('keyModal').classList.add('open');
|
|
setTimeout(() => document.getElementById('kName').focus(), 100);
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('keyModal').classList.remove('open');
|
|
}
|
|
|
|
function showSecret(secret) {
|
|
document.getElementById('secretValue').value = secret;
|
|
document.getElementById('secretModal').classList.add('open');
|
|
}
|
|
|
|
async function saveKey() {
|
|
const name = document.getElementById('kName').value.trim();
|
|
if (!name) { showToast('Введите название', true); return; }
|
|
const scopes = [];
|
|
if (document.getElementById('kRead').checked) scopes.push('read');
|
|
if (document.getElementById('kWrite').checked) scopes.push('write');
|
|
if (!scopes.length) { showToast('Выберите хотя бы одно право', true); return; }
|
|
const expires = document.getElementById('kExpires').value;
|
|
const res = await fetch(`${API}/api/api-keys`, {
|
|
method: 'POST',
|
|
headers: hdrJson(),
|
|
body: JSON.stringify({
|
|
name,
|
|
scopes,
|
|
branch_ids: selectedBranches(),
|
|
rate_limit_per_min: document.getElementById('kRate').value || null,
|
|
expires_at: expires || null,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) { showToast(data.error || 'Ошибка создания ключа', true); return; }
|
|
closeModal();
|
|
showSecret(data.key);
|
|
await loadKeys();
|
|
}
|
|
|
|
async function rotateKey(id) {
|
|
if (!window.confirm('Перевыпустить ключ? Старый ключ перестанет работать.')) return;
|
|
const res = await fetch(`${API}/api/api-keys/${id}/rotate`, { method: 'POST', headers: hdr() });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) { showToast(data.error || 'Ошибка ротации', true); return; }
|
|
showSecret(data.key);
|
|
await loadKeys();
|
|
}
|
|
|
|
async function deleteKey(id) {
|
|
if (!window.confirm('Удалить API-ключ? Он перестанет работать немедленно.')) return;
|
|
const res = await fetch(`${API}/api/api-keys/${id}`, { method: 'DELETE', headers: hdr() });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) { showToast(data.error || 'Ошибка удаления', true); return; }
|
|
showToast('Ключ удалён');
|
|
await loadKeys();
|
|
}
|
|
|
|
async function init() {
|
|
if (!(await requireAdminPage())) return;
|
|
buildSidebar('apikeys');
|
|
document.getElementById('addKeyBtn').addEventListener('click', openModal);
|
|
document.getElementById('kCancel').addEventListener('click', closeModal);
|
|
document.getElementById('kSave').addEventListener('click', saveKey);
|
|
document.getElementById('secretClose').addEventListener('click', () => document.getElementById('secretModal').classList.remove('open'));
|
|
document.getElementById('secretCopy').addEventListener('click', async () => {
|
|
const field = document.getElementById('secretValue');
|
|
try {
|
|
await navigator.clipboard.writeText(field.value);
|
|
showToast('Ключ скопирован');
|
|
} catch {
|
|
field.select();
|
|
showToast('Скопируйте ключ вручную', true);
|
|
}
|
|
});
|
|
document.querySelector('#keysTable tbody').addEventListener('click', (e) => {
|
|
const rot = e.target.closest('[data-rot]');
|
|
if (rot) return rotateKey(rot.dataset.rot);
|
|
const del = e.target.closest('[data-del]');
|
|
if (del) return deleteKey(del.dataset.del);
|
|
});
|
|
await loadKeys();
|
|
}
|
|
|
|
init(); |