Отдельный префикс /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.
178 lines
12 KiB
JavaScript
178 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function loadEnv() {
|
|
const file = path.join(__dirname, '.env');
|
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
|
}
|
|
}
|
|
|
|
const BASE = process.env.BASE || 'http://localhost:3003';
|
|
|
|
async function api(pathname, { token, apiKey, method = 'GET', body, headers: extra } = {}) {
|
|
const headers = {};
|
|
if (token) headers['X-Auth-Token'] = token;
|
|
if (apiKey) headers['X-Api-Key'] = apiKey;
|
|
if (body) headers['Content-Type'] = 'application/json';
|
|
Object.assign(headers, extra || {});
|
|
const res = await fetch(BASE + pathname, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
const text = await res.text();
|
|
let data = text;
|
|
try { data = JSON.parse(text); } catch (e) {}
|
|
return { status: res.status, data, headers: res.headers };
|
|
}
|
|
|
|
function ok(label, cond, extra) {
|
|
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && extra !== null ? ' -> ' + JSON.stringify(extra) : ''}`);
|
|
if (!cond) process.exitCode = 1;
|
|
return cond;
|
|
}
|
|
|
|
const V1 = (key, p, opts) => api('/api/v1' + p, { ...opts, apiKey: key });
|
|
|
|
const unbanSelf = async (token) => {
|
|
const bans = await api('/api/bans', { token });
|
|
if (!Array.isArray(bans.data)) return;
|
|
for (const b of bans.data) {
|
|
if (b.reason === 'apikey-bruteforce') await api('/api/bans/' + encodeURIComponent(b.ip), { token, method: 'DELETE' });
|
|
}
|
|
};
|
|
|
|
async function main() {
|
|
loadEnv();
|
|
const login = await api('/api/auth/login', { method: 'POST', body: { username: process.env.ADMIN_USERNAME || 'admin', password: process.env.ADMIN_PASSWORD } });
|
|
if (login.status === 403) {
|
|
console.log('IP заблокирован защитой от подбора ключей (тест делает несколько заведомо неверных ключей за прогон).');
|
|
console.log('Снимите бан в админке «Блокировки» и повторите запуск.');
|
|
return;
|
|
}
|
|
if (!ok('login', login.status === 200 && login.data.token, login.status)) return;
|
|
const token = login.data.token;
|
|
await unbanSelf(token);
|
|
|
|
const created = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest read ' + Date.now(), scopes: ['read'] } });
|
|
ok('api-keys: создание ключа -> 201 с секретом',
|
|
created.status === 201 && typeof created.data.key === 'string' && created.data.key.startsWith('wsk_'),
|
|
{ status: created.status, prefix: created.data && created.data.prefix });
|
|
const rawKey = created.data.key;
|
|
const keyId = created.data.id;
|
|
|
|
const meta = await api('/api/api-keys/meta', { token });
|
|
ok('api-keys: meta отдаёт scopes', meta.status === 200 && meta.data.scopes && meta.data.scopes.read && meta.data.scopes.write, meta.data);
|
|
ok('api-keys: секрет не возвращается в листинге',
|
|
await api('/api/api-keys', { token }).then(r => Array.isArray(r.data) && r.data.every(k => k.key === undefined)), null);
|
|
|
|
const me = await V1(rawKey, '/me');
|
|
ok('api v1: /me по X-Api-Key -> 200', me.status === 200 && me.data.user && me.data.user.role === 'admin', me.data && me.data.user);
|
|
ok('api v1: Authorization Bearer тоже работает', (await api('/api/v1/me', { headers: { Authorization: 'Bearer ' + rawKey } })).status === 200, null);
|
|
ok('api v1: секрет ключа не утекает в /me', me.data.key && me.data.key.key === undefined, me.data.key);
|
|
ok('api v1: без ключа -> 401', (await api('/api/v1/me')).status === 401, null);
|
|
ok('api v1: неверный ключ -> 401', (await api('/api/v1/me', { headers: { 'X-Api-Key': 'wsk_' + '0'.repeat(64) } })).status === 401, null);
|
|
ok('api v1: токен сессии не работает как API-ключ', (await api('/api/v1/me', { headers: { 'X-Api-Key': token } })).status === 401, null);
|
|
|
|
for (const p of ['/branches', '/groups', '/students', '/modules', '/entries', '/lesson-reports', '/stats']) {
|
|
const r = await V1(rawKey, p);
|
|
ok('api v1: чтение ' + p, r.status === 200, { status: r.status, error: r.data && r.data.error });
|
|
}
|
|
const gList = await V1(rawKey, '/groups?limit=2');
|
|
ok('api v1: список возвращает {items,total,limit,offset}',
|
|
gList.status === 200 && Array.isArray(gList.data.items) && typeof gList.data.total === 'number' && gList.data.limit === 2,
|
|
{ status: gList.status, keys: Object.keys(gList.data || {}) });
|
|
|
|
const g = (gList.data.items && gList.data.items[0]) || null;
|
|
const studentName = 'SelftestApi ' + Date.now();
|
|
if (g) {
|
|
const denied = await V1(rawKey, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'read-only' } });
|
|
ok('api v1: ключ без scope write -> 403', denied.status === 403, { status: denied.status, error: denied.data && denied.data.error });
|
|
} else {
|
|
ok('api v1: есть группа для проверки записи', false, 'no groups');
|
|
}
|
|
|
|
const writeKey = await api('/api/api-keys', { token, method: 'POST', body: { name: 'SelfTest write ' + Date.now(), scopes: ['read', 'write'] } });
|
|
ok('api-keys: создание ключа со scope write', writeKey.status === 201, writeKey.status);
|
|
|
|
if (g) {
|
|
const ce = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Создано через API' } });
|
|
ok('api v1: POST /entries со scope write -> 201', ce.status === 201 && ce.data.id > 0, { status: ce.status, error: ce.data && ce.data.error });
|
|
if (ce.status === 201) {
|
|
const upd = await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'PUT', body: { description: 'Обновлено через API' } });
|
|
ok('api v1: PUT /entries/:id', upd.status === 200 && upd.data.description === 'Обновлено через API', { status: upd.status, error: upd.data && upd.data.error });
|
|
const one = await V1(writeKey.data.key, '/entries/' + ce.data.id);
|
|
ok('api v1: GET /entries/:id отдаёт изменённое', one.status === 200 && one.data.description === 'Обновлено через API', one.status);
|
|
ok('api v1: GET /entries/:id/files', (await V1(writeKey.data.key, '/entries/' + ce.data.id + '/files')).status === 200, null);
|
|
ok('api v1: DELETE /entries/:id (soft delete)', (await V1(writeKey.data.key, '/entries/' + ce.data.id, { method: 'DELETE' })).status === 200, null);
|
|
const after = await V1(writeKey.data.key, '/entries?search=' + encodeURIComponent(studentName));
|
|
ok('api v1: удалённая запись не выдаётся в списке',
|
|
after.status === 200 && !after.data.items.some(i => i.id === ce.data.id),
|
|
{ status: after.status, ids: (after.data.items || []).map(i => i.id) });
|
|
}
|
|
const lessonDate = new Date().toISOString().slice(0, 10);
|
|
const lr = await V1(writeKey.data.key, '/lesson-reports', { method: 'POST', body: { group_id: g.id, lesson_date: lessonDate, lesson_time: '10:00', text: 'Отчёт через API' } });
|
|
ok('api v1: POST /lesson-reports', lr.status === 201 && lr.data.id > 0, { status: lr.status, error: lr.data && lr.data.error });
|
|
if (lr.status === 201) {
|
|
const lrList = await V1(writeKey.data.key, '/lesson-reports?group_id=' + g.id + '&date_from=' + lessonDate);
|
|
ok('api v1: отчёт виден в списке по дате', lrList.status === 200 && lrList.data.items.some(r => r.id === lr.data.id), { status: lrList.status, total: lrList.data && lrList.data.total });
|
|
ok('api v1: DELETE /lesson-reports/:id', (await V1(writeKey.data.key, '/lesson-reports/' + lr.data.id, { method: 'DELETE' })).status === 200, null);
|
|
}
|
|
}
|
|
|
|
const rot = await api('/api/api-keys/' + writeKey.data.id + '/rotate', { token, method: 'POST' });
|
|
ok('api-keys: ротация выдаёт новый секрет', rot.status === 200 && typeof rot.data.key === 'string' && rot.data.key !== writeKey.data.key, rot.status);
|
|
ok('api v1: старый ключ мёртв после ротации', (await api('/api/v1/me', { apiKey: writeKey.data.key })).status === 401, null);
|
|
ok('api v1: новый ключ работает после ротации', (await api('/api/v1/me', { apiKey: rot.data.key })).status === 200, null);
|
|
await api('/api/api-keys/' + writeKey.data.id, { token, method: 'DELETE' });
|
|
|
|
const updKey = await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { name: 'Renamed ' + Date.now(), scopes: ['read'] } });
|
|
ok('api-keys: PUT обновляет ключ', updKey.status === 200 && updKey.data.name.startsWith('Renamed'), updKey.status);
|
|
ok('api-keys: некорректный лимит -> 400', (await api('/api/api-keys/' + keyId, { token, method: 'PUT', body: { rate_limit_per_min: 999999 } })).status === 400, null);
|
|
ok('api-keys: DELETE ключа', (await api('/api/api-keys/' + keyId, { token, method: 'DELETE' })).status === 200, null);
|
|
ok('api v1: удалённый ключ -> 401', (await api('/api/v1/me', { apiKey: rawKey })).status === 401, null);
|
|
ok('api-keys: список без сессии -> 401', (await api('/api/api-keys')).status === 401, null);
|
|
ok('api-keys: X-Admin-Token не авторизует -> 401', (await api('/api/api-keys', { headers: { 'X-Admin-Token': token } })).status === 401, null);
|
|
|
|
const limited = await api('/api/api-keys', { token, method: 'POST', body: { name: 'RL test', scopes: ['read'], rate_limit_per_min: 3 } });
|
|
const rlKey = limited.data.key;
|
|
const codes = [];
|
|
let rlHeaders = null;
|
|
for (let i = 0; i < 5; i++) {
|
|
const r = await api('/api/v1/stats', { apiKey: rlKey });
|
|
codes.push(r.status);
|
|
rlHeaders = r.headers;
|
|
}
|
|
ok('api-keys: индивидуальный лимит rpm соблюдается',
|
|
codes.slice(0, 3).every(c => c === 200) && codes.slice(3).some(c => c === 429),
|
|
{ codes, limit: rlHeaders && rlHeaders.get('ratelimit-limit') });
|
|
ok('api-keys: заголовки ratelimit присутствуют', Boolean(rlHeaders && rlHeaders.get('ratelimit-limit')), null);
|
|
await api('/api/api-keys/' + limited.data.id, { token, method: 'DELETE' });
|
|
|
|
const expired = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Expired', scopes: ['read'], expires_at: '2000-01-01T00:00:00Z' } });
|
|
ok('api v1: просроченный ключ -> 401', (await api('/api/v1/me', { apiKey: expired.data.key })).status === 401, null);
|
|
await api('/api/api-keys/' + expired.data.id, { token, method: 'DELETE' });
|
|
|
|
const branches = await api('/api/branches', { token });
|
|
if (Array.isArray(branches.data) && branches.data.length >= 1) {
|
|
const only = await api('/api/api-keys', { token, method: 'POST', body: { name: 'Branch 1 ' + Date.now(), scopes: ['read'], branch_ids: [branches.data[0].id] } });
|
|
ok('api-keys: ключ с ограничением по филиалу создан', only.status === 201, only.status);
|
|
const scoped = await api('/api/v1/branches', { apiKey: only.data.key });
|
|
ok('api v1: ключ видит только свой филиал',
|
|
scoped.status === 200 && scoped.data.items.length === 1 && scoped.data.items[0].id === branches.data[0].id,
|
|
{ status: scoped.status, ids: (scoped.data.items || []).map(b => b.id) });
|
|
const sc = await api('/api/v1/me', { apiKey: only.data.key });
|
|
ok('api v1: филиал ограничивает права доступа',
|
|
sc.data.user.role === 'tutor' && sc.data.user.branch_ids.length === 1 && sc.data.user.branch_ids[0] === branches.data[0].id,
|
|
sc.data.user);
|
|
const bad = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { branch_ids: [999999] } });
|
|
ok('api-keys: несуществующий филиал -> 400', bad.status === 400, bad.status);
|
|
await api('/api/api-keys/' + only.data.id, { token, method: 'DELETE' });
|
|
} else {
|
|
ok('api v1: есть филиал для проверки скоупа', false, 'no branches');
|
|
}
|
|
|
|
await unbanSelf(token);
|
|
|
|
console.log('\nAPI KEYS SELFTEST DONE');
|
|
}
|
|
|
|
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); }); |