feat(api): внешний API и API-ключи для интеграций
Отдельный префикс /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.
This commit is contained in:
+3
-1
@@ -63,6 +63,7 @@ function buildSidebar(active) {
|
||||
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||
{ page: 'apikeys', label: 'API-ключи', icon: 'key-round' },
|
||||
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||
{ page: 'audit', label: 'Аудит', icon: 'scroll-text' },
|
||||
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||
@@ -399,10 +400,11 @@ function renderIcons() {
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
function showToast(msg, isError) {
|
||||
const t = document.getElementById('toast');
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('error', !!isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>API-ключи — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
</head>
|
||||
<body data-page="apikeys">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>API-ключи</h2>
|
||||
<p class="page-sub">Доступ к данным из внешних систем по HTTP API</p>
|
||||
</div>
|
||||
|
||||
<div class="actions-row" style="margin-bottom:16px">
|
||||
<button class="btn-primary" id="addKeyBtn" type="button">
|
||||
<span style="font-size:1.2rem">+</span> Создать ключ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="keysTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Ключ</th>
|
||||
<th>Права</th>
|
||||
<th>Филиалы</th>
|
||||
<th>Создан</th>
|
||||
<th>Использован</th>
|
||||
<th>Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="keysEmpty" style="display:none">API-ключи не созданы</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" style="margin-top:16px">
|
||||
<div class="card-head">
|
||||
<i data-lucide="terminal"></i>
|
||||
<h3>Как использовать</h3>
|
||||
</div>
|
||||
<p class="hint">Базовый адрес: <code>/api/v1</code>. Ключ передаётся в заголовке <code>X-Api-Key</code> или <code>Authorization: Bearer <ключ></code>.</p>
|
||||
<pre class="code-block">curl -H "X-Api-Key: wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/api/v1/groups
|
||||
|
||||
curl -H "X-Api-Key: wsk_ВАШ_КЛЮЧ" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"student_name":"Иван","group_id":1,"description":"Сделал проект"}' \
|
||||
https://ВАШ_ДОМЕН/api/v1/entries</pre>
|
||||
<p class="hint">Секрет показывается один раз при создании и ротации — в базе хранится только SHA-256 хеш. Эндпоинты: <code>/me</code>, <code>/branches</code>, <code>/groups</code>, <code>/students</code>, <code>/modules</code>, <code>/entries</code>, <code>/lesson-reports</code>, <code>/stats</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="keyModal">
|
||||
<div class="edit-modal" style="max-width:520px">
|
||||
<h3 id="keyModalTitle">Новый API-ключ</h3>
|
||||
<div class="settings-stack" style="gap:12px;max-width:none">
|
||||
<div class="settings-field">
|
||||
<label>Название <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="kName" class="settings-input" placeholder="Интеграция с 1С" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Права доступа</label>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-weight:400">
|
||||
<input type="checkbox" id="kRead" checked> Чтение
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-weight:400">
|
||||
<input type="checkbox" id="kWrite"> Запись
|
||||
</label>
|
||||
</div>
|
||||
<span class="hint" style="font-size:.75rem">Без права «Запись» ключ сможет только читать данные.</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Филиалы (мультивыбор)</label>
|
||||
<select id="kBranches" class="settings-input" multiple size="4"></select>
|
||||
<span class="hint" style="font-size:.75rem">Пусто — доступ ко всем вашим филиалам. Выбор: Ctrl/Cmd+клик.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Лимит запросов в минуту</label>
|
||||
<input type="number" id="kRate" class="settings-input" min="1" max="10000" placeholder="120">
|
||||
</div>
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Действует до</label>
|
||||
<input type="date" id="kExpires" class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||
<button type="button" class="btn-primary ghost" id="kCancel">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="kSave">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="secretModal">
|
||||
<div class="edit-modal" style="max-width:560px">
|
||||
<h3>Ключ создан</h3>
|
||||
<p class="hint">Скопируйте ключ сейчас — второй раз он не показывается.</p>
|
||||
<div class="settings-field">
|
||||
<label>Ключ</label>
|
||||
<input type="text" id="secretValue" class="settings-input mono" readonly>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||
<button type="button" class="btn-primary ghost" id="secretCopy">Скопировать</button>
|
||||
<button type="button" class="btn-primary" id="secretClose">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="js/datetime.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="js/apikeys.js"></script>
|
||||
<style>
|
||||
.status-ok{font-size:.72rem;font-weight:600;color:#16a34a;background:rgba(22,163,74,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||
.status-off{font-size:.72rem;font-weight:600;color:#ef4444;background:rgba(239,68,68,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||
.status-warn{font-size:.72rem;font-weight:600;color:#d97706;background:rgba(217,119,6,.12);border-radius:999px;padding:2px 10px;white-space:nowrap}
|
||||
.row-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px 8px;border-radius:6px}
|
||||
.row-btn:hover{background:var(--bg);color:var(--accent)}
|
||||
.row-btn.del:hover{color:#ef4444;background:rgba(239,68,68,.1)}
|
||||
.key-prefix{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;color:var(--muted)}
|
||||
.key-scopes{font-size:.72rem;color:var(--muted)}
|
||||
.key-meta{font-size:.74rem;color:var(--muted);white-space:nowrap}
|
||||
.code-block{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;overflow-x:auto;font-size:.78rem;line-height:1.5;margin:8px 0}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,146 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user