From 3d8939a6aae6252215bfaff7eae087b12d37f5a0 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Sat, 28 Mar 2026 21:20:55 +0700 Subject: [PATCH] feat: Guests API keys. Closes #3 --- app/api/deps.py | 69 ++++- app/api/routes/api_keys.py | 85 ++++++ app/api/routes/devices.py | 8 +- app/api/routes/schedules.py | 4 +- app/models/api_key.py | 21 ++ main.py | 11 +- static/index.html | 499 +++++++++--------------------------- 7 files changed, 297 insertions(+), 400 deletions(-) create mode 100644 app/api/routes/api_keys.py create mode 100644 app/models/api_key.py diff --git a/app/api/deps.py b/app/api/deps.py index 254cad4..862a67a 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1,27 +1,74 @@ import os import logging -from fastapi import Depends, HTTPException, Security +from dataclasses import dataclass +from typing import Optional +from fastapi import Depends, HTTPException from fastapi.security import APIKeyHeader from starlette.status import HTTP_403_FORBIDDEN from dotenv import load_dotenv +from sqlalchemy import select + +from app.core.database import async_session +from app.models.api_key import ApiKeyModel load_dotenv() logger = logging.getLogger(__name__) -API_KEY = os.getenv("IGNIS_API_KEY") -if not API_KEY: +MASTER_KEY = os.getenv("IGNIS_API_KEY") +if not MASTER_KEY: logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!") API_KEY_NAME = "X-API-Key" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) -async def verify_token(header_value: str = Depends(api_key_header)): - if not API_KEY: - return None - if header_value == API_KEY: - return header_value - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" - ) +@dataclass +class AuthContext: + """Результат авторизации -- передаётся в роуты через Depends.""" + is_master: bool # мастер-ключ из .env + is_admin: bool # право на CRUD групп, расписания, ресканирование + key_name: str # имя ключа (для логов) + + +async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext: + """ + Проверка API-ключа: + 1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ + 2. Мастер-ключ из .env -- полный доступ + 3. Ключ из БД (api_keys) -- проверяем active и is_admin + 4. Иначе -- 403 + """ + # Авторизация отключена + if not MASTER_KEY: + return AuthContext(is_master=True, is_admin=True, key_name="no-auth") + + if not header_value: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="API-ключ не передан") + + # Мастер-ключ + if header_value == MASTER_KEY: + return AuthContext(is_master=True, is_admin=True, key_name="master") + + # Ищем в БД + async with async_session() as session: + result = await session.execute( + select(ApiKeyModel).where(ApiKeyModel.key == header_value) + ) + api_key = result.scalar_one_or_none() + + if api_key and api_key.active: + return AuthContext( + is_master=False, + is_admin=api_key.is_admin, + key_name=api_key.name, + ) + + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Неверный или деактивированный ключ") + + +def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext: + """Dependency для роутов, требующих админских прав.""" + if not auth.is_admin: + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав") + return auth diff --git a/app/api/routes/api_keys.py b/app/api/routes/api_keys.py new file mode 100644 index 0000000..2d5aa9c --- /dev/null +++ b/app/api/routes/api_keys.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select + +from app.core.database import async_session +from app.models.api_key import ApiKeyModel +from app.api.deps import require_admin, AuthContext + +# Все операции с ключами -- только для админов (мастер-ключ) +router = APIRouter(dependencies=[Depends(require_admin)]) + + +@router.get("") +async def list_keys(): + """Список всех гостевых ключей.""" + async with async_session() as session: + result = await session.execute(select(ApiKeyModel)) + keys = result.scalars().all() + + return [ + { + "key": k.key, + "name": k.name, + "is_admin": k.is_admin, + "active": k.active, + "created_at": k.created_at, + } + for k in keys + ] + + +@router.post("") +async def create_key(name: str, is_admin: bool = False): + """Создать гостевой ключ. Возвращает сгенерированный токен.""" + new_key = ApiKeyModel( + key=ApiKeyModel.generate_key(), + name=name, + is_admin=is_admin, + ) + + async with async_session() as session: + session.add(new_key) + await session.commit() + + return { + "key": new_key.key, + "name": new_key.name, + "is_admin": new_key.is_admin, + "message": "Сохраните ключ -- он больше не будет показан полностью", + } + + +@router.delete("/{key}") +async def revoke_key(key: str): + """Деактивировать (отозвать) гостевой ключ.""" + async with async_session() as session: + result = await session.execute( + select(ApiKeyModel).where(ApiKeyModel.key == key) + ) + api_key = result.scalar_one_or_none() + if not api_key: + raise HTTPException(status_code=404, detail="Ключ не найден") + + api_key.active = False + session.add(api_key) + await session.commit() + + return {"status": "revoked", "name": api_key.name} + + +@router.post("/{key}/activate") +async def activate_key(key: str): + """Повторно активировать ключ.""" + async with async_session() as session: + result = await session.execute( + select(ApiKeyModel).where(ApiKeyModel.key == key) + ) + api_key = result.scalar_one_or_none() + if not api_key: + raise HTTPException(status_code=404, detail="Ключ не найден") + + api_key.active = True + session.add(api_key) + await session.commit() + + return {"status": "activated", "name": api_key.name} diff --git a/app/api/routes/devices.py b/app/api/routes/devices.py index 7ebaae4..9d522f3 100644 --- a/app/api/routes/devices.py +++ b/app/api/routes/devices.py @@ -3,7 +3,7 @@ from sqlalchemy import select from app.core.state import state_manager, discovery_service from app.core.database import async_session from app.models.device import GroupModel, GroupCreateSchema -from app.api.deps import verify_token +from app.api.deps import verify_token, require_admin from app.drivers.wiz import WizDriver # Создаем роутер с защитой @@ -26,7 +26,7 @@ async def get_scenes(): return wiz.SCENES -@router.post("/groups") +@router.post("/groups", dependencies=[Depends(require_admin)]) async def create_group(data: GroupCreateSchema): async with async_session() as session: existing = await session.get(GroupModel, data.id) @@ -40,7 +40,7 @@ async def create_group(data: GroupCreateSchema): return {"status": "created", "group": data.name} -@router.delete("/groups/{group_id}") +@router.delete("/groups/{group_id}", dependencies=[Depends(require_admin)]) async def delete_group(group_id: str): async with async_session() as session: result = await session.execute( @@ -56,7 +56,7 @@ async def delete_group(group_id: str): return {"status": "deleted", "id": group_id} -@router.post("/rescan") +@router.post("/rescan", dependencies=[Depends(require_admin)]) async def rescan_network(): found_devices = await discovery_service.scan_network() for dev_data in found_devices: diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py index 1e30ce5..4531bdd 100644 --- a/app/api/routes/schedules.py +++ b/app/api/routes/schedules.py @@ -8,11 +8,11 @@ from apscheduler.triggers.date import DateTrigger from app.core.scheduler import app_tz, scheduler from app.core.state import state_manager from app.drivers.wiz import WizDriver -from app.api.deps import verify_token +from app.api.deps import require_admin logger = logging.getLogger(__name__) -router = APIRouter(dependencies=[Depends(verify_token)]) +router = APIRouter(dependencies=[Depends(require_admin)]) async def run_group_command(target_id: str, is_group: bool, params: dict): diff --git a/app/models/api_key.py b/app/models/api_key.py new file mode 100644 index 0000000..9601410 --- /dev/null +++ b/app/models/api_key.py @@ -0,0 +1,21 @@ +import secrets +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class ApiKeyModel(Base): + """Гостевой API-ключ с ограниченными правами.""" + __tablename__ = "api_keys" + + key: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String) # "Вася", "гости" + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # доступ к CRUD групп, расписаниям + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[str] = mapped_column(String, default=lambda: datetime.now().isoformat()) + + @staticmethod + def generate_key() -> str: + """Генерация безопасного случайного токена.""" + return secrets.token_urlsafe(32) diff --git a/main.py b/main.py index b8f71cb..57bd550 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ import logging import asyncio import os from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Depends from fastapi.staticfiles import StaticFiles from app.core.database import init_db, async_session @@ -10,7 +10,8 @@ from app.core.scheduler import start_scheduler from app.core.state import state_manager, discovery_service from sqlalchemy import select from app.models.device import GroupModel -from app.api.routes import devices, control, schedules +from app.api.routes import devices, control, schedules, api_keys +from app.api.deps import verify_token LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() @@ -48,6 +49,7 @@ app = FastAPI(title="Ignis Core API", lifespan=lifespan) app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"]) app.include_router(control.router, prefix="/control", tags=["Control"]) app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"]) +app.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"]) # Статика # Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html @@ -61,6 +63,11 @@ async def read_index(): return FileResponse("static/index.html") +@app.get("/auth/me") +async def auth_me(auth = Depends(verify_token)): + return {"is_admin": auth.is_admin, "name": auth.key_name} + + if __name__ == "__main__": import uvicorn diff --git a/static/index.html b/static/index.html index 4774cc2..97c5381 100644 --- a/static/index.html +++ b/static/index.html @@ -9,75 +9,25 @@ @@ -85,27 +35,22 @@
- +

Ignis

-

API-ключ из .env сервера

- API-ключ

+ - +
- + - +
-
- {{ toast.text }} -
+
{{ toast.text }}
@@ -351,260 +226,122 @@ apiKey: localStorage.getItem('ignis_key') || '', tempKey: '', tab: 'control', - groups: {}, - devices: [], - sliders: {}, + isAdmin: false, + authName: '', + groups: {}, devices: [], sliders: {}, newGroup: { id: '', name: '', macs: [] }, - isLoading: false, - isLoadingStatus: false, - isFetching: false, // защита от параллельных fetchData - isRescanning: false, - taskHour: '22', - taskMin: '00', + isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false, + taskHour: '22', taskMin: '00', newTask: { target_id: '', state: true }, - tasks: [], - allScenes: {}, // загружается с бэкенда - toasts: [], - toastCounter: 0, + tasks: [], allScenes: {}, + toasts: [], toastCounter: 0, } }, methods: { - // ─── Утилиты ───────────────────────────────── saveKey() { - if (this.tempKey) { - this.apiKey = this.tempKey; - localStorage.setItem('ignis_key', this.tempKey); - this.fetchData(); - } + if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); } }, - logout() { - this.apiKey = ''; - localStorage.removeItem('ignis_key'); - location.reload(); - }, - // Toast-уведомление + logout() { this.apiKey = ''; this.isAdmin = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); }, toast(text, type = 'info', duration = 3000) { - const id = ++this.toastCounter; - this.toasts.push({ id, text, type }); - setTimeout(() => { - this.toasts = this.toasts.filter(t => t.id !== id); - }, duration); - }, - // Имя группы по id (для расписаний) - getGroupName(targetId) { - const g = this.groups[targetId]; - return g ? g.name : null; + const id = ++this.toastCounter; this.toasts.push({ id, text, type }); + setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration); }, + getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; }, - // ─── HTTP ──────────────────────────────────── async request(path, method = 'GET', params = null, body = null) { let url = path; - if (params) { - const q = new URLSearchParams(params).toString(); - url += `?${q}`; - } + if (params) url += `?${new URLSearchParams(params).toString()}`; try { - const response = await fetch(url, { - method, - headers: { - 'X-API-Key': this.apiKey, - 'Content-Type': 'application/json' - }, - body: body ? JSON.stringify(body) : null - }); - if (response.status === 403) { - this.toast('Неверный API-ключ', 'error'); - this.logout(); - return null; + const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null }); + if (r.status === 403) { + const err = await r.json().catch(() => ({})); + if (err.detail === 'Недостаточно прав') { this.toast('Нет прав', 'error'); return null; } + this.toast('Неверный API-ключ', 'error'); this.logout(); return null; } - if (!response.ok) { - const err = await response.json().catch(() => ({})); - this.toast(err.detail || `Ошибка ${response.status}`, 'error'); - return null; - } - return await response.json(); - } catch (e) { - this.toast('Сервер недоступен', 'error'); - return null; - } + if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; } + return await r.json(); + } catch (e) { this.toast('Сервер недоступен', 'error'); return null; } + }, + + async initApp() { + this.isLoading = true; + const auth = await this.request('/auth/me'); + if (!auth) { this.isLoading = false; return; } + this.isAdmin = auth.is_admin; + this.authName = auth.name; + await this.fetchData(); + this.isLoading = false; + setInterval(() => this.fetchData(), 15000); }, - // ─── Данные ────────────────────────────────── async fetchData() { if (!this.apiKey || this.isFetching) return; this.isFetching = true; - try { const [gData, dData, sData] = await Promise.all([ - this.request('/devices/groups'), - this.request('/devices'), - this.request('/devices/scenes'), + this.request('/devices/groups'), this.request('/devices'), this.request('/devices/scenes'), ]); - if (gData) { this.groups = gData; - // Инициализируем слайдеры для новых групп - Object.keys(this.groups).forEach(id => { - if (!this.sliders[id]) { - this.sliders[id] = { brightness: 100, temp: 4000, state: false }; - } - }); + Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; }); await this.syncGroupStatuses(); } - if (dData) { - // Бэкенд может вернуть dict или list - this.devices = Array.isArray(dData) ? dData : Object.values(dData); - } - // Сцены с бэкенда (dict: {name: sceneId}) + if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData); if (sData) this.allScenes = sData; - this.fetchTasks(); - } finally { - this.isFetching = false; - } + if (this.isAdmin) this.fetchTasks(); + } finally { this.isFetching = false; } }, - // ─── Управление ────────────────────────────── - async control(id, params) { - await this.request(`/control/group/${id}`, 'POST', params); - // Не делаем полный syncGroupStatuses -- ждём следующий цикл - }, - toggleGroup(id, state) { - if (this.sliders[id]) { - this.sliders[id].state = state; - } - this.control(id, { state }); - }, - setBrightness(id, val) { - if (this.sliders[id]) this.sliders[id].brightness = val; - this.control(id, { brightness: val }); - }, - setTemp(id, val) { - if (this.sliders[id]) this.sliders[id].temp = val; - this.control(id, { temp: val }); - }, + async control(id, params) { await this.request(`/control/group/${id}`, 'POST', params); }, + toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); }, + setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); }, + setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); }, setScene(id, scene) { this.control(id, { scene }); }, - setColor(id, hex) { - const r = parseInt(hex.slice(1,3),16); - const g = parseInt(hex.slice(3,5),16); - const b = parseInt(hex.slice(5,7),16); - this.control(id, { r, g, b }); - }, + setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); }, - // ─── Группы ────────────────────────────────── async createGroup() { - const res = await this.request('/devices/groups', 'POST', null, { - id: this.newGroup.id, - name: this.newGroup.name, - macs: this.newGroup.macs - }); - if (res) { - this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); - this.newGroup = { id: '', name: '', macs: [] }; - await this.fetchData(); - this.tab = 'control'; - } + const res = await this.request('/devices/groups', 'POST', null, { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs }); + if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; } }, async deleteGroup(id) { const name = this.groups[id]?.name || id; - if (confirm(`Удалить группу "${name}"?`)) { - await this.request(`/devices/groups/${id}`, 'DELETE'); - this.toast(`Группа "${name}" удалена`, 'success'); - await this.fetchData(); - } + if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, 'DELETE'); this.toast(`Удалена`, 'success'); await this.fetchData(); } }, async rescan() { - this.isRescanning = true; - await this.request('/devices/rescan', 'POST'); - this.toast('Сканирование запущено...', 'info'); - setTimeout(async () => { - await this.fetchData(); - this.isRescanning = false; - this.toast(`Найдено ${this.devices.length} устройств`, 'success'); - }, 3000); + this.isRescanning = true; await this.request('/devices/rescan', 'POST'); this.toast('Сканирование...', 'info'); + setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000); }, - async blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); }, + async blink(id) { await this.request(`/control/device/${id}/blink`, 'POST'); }, - // ─── Синхронизация состояния ───────────────── async syncGroupStatuses() { - if (this.isLoadingStatus) return; - this.isLoadingStatus = true; - + if (this.isLoadingStatus) return; this.isLoadingStatus = true; try { - // Параллельный опрос всех групп - const groupIds = Object.keys(this.groups); - const results = await Promise.all( - groupIds.map(id => this.request(`/control/group/${id}/status`)) - ); - - groupIds.forEach((id, i) => { - const data = results[i]; - if (data && data.results && data.results.length > 0) { - const firstValid = data.results.find(r => r.status && !r.error); - if (firstValid) { - const s = firstValid.status; - this.sliders[id] = { - brightness: s.dimming || 100, - temp: s.temp || 4000, - state: s.state || false, - }; - } + const ids = Object.keys(this.groups); + const results = await Promise.all(ids.map(id => this.request(`/control/group/${id}/status`))); + ids.forEach((id, i) => { + const d = results[i]; + if (d?.results?.length > 0) { + const v = d.results.find(r => r.status && !r.error); + if (v) this.sliders[id] = { brightness: v.status.dimming || 100, temp: v.status.temp || 4000, state: v.status.state || false }; } }); - } finally { - this.isLoadingStatus = false; - } + } finally { this.isLoadingStatus = false; } }, - // ─── Расписания ────────────────────────────── - async fetchTasks() { - const data = await this.request('/schedules/tasks'); - if (data) this.tasks = data.tasks || []; - }, + async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; }, async addSchedule() { - if (!this.newTask.target_id) { - this.toast('Выберите группу', 'error'); - return; - } - const res = await this.request('/schedules/cron', 'POST', { - target_id: this.newTask.target_id, - hour: this.taskHour, - minute: this.taskMin, - is_group: true, - state: this.newTask.state - }); - if (res) { - this.toast(`Задача добавлена: ${this.taskHour}:${this.taskMin}`, 'success'); - this.fetchTasks(); - } - }, - async deleteTask(id) { - await this.request(`/schedules/${id}`, 'DELETE'); - this.toast('Задача отменена', 'success'); - this.fetchTasks(); + if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; } + const res = await this.request('/schedules/cron', 'POST', { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state }); + if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); } }, + async deleteTask(id) { await this.request(`/schedules/${id}`, 'DELETE'); this.toast('Отменено', 'success'); this.fetchTasks(); }, async setTimer4h(id) { await this.toggleGroup(id, true); - const res = await this.request('/schedules/once', 'POST', { - target_id: id, - hours_from_now: 4, - is_group: true, - state: false - }); - if (res) { - this.toast('Таймер 4ч установлен', 'success'); - this.fetchTasks(); - } + const res = await this.request('/schedules/once', 'POST', { target_id: id, hours_from_now: 4, is_group: true, state: false }); + if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); } }, }, - async mounted() { - if (this.apiKey) { - this.isLoading = true; - await this.fetchData(); - this.isLoading = false; - // Периодический опрос с защитой от наложений - setInterval(() => this.fetchData(), 15000); - } - } + async mounted() { if (this.apiKey) await this.initApp(); } }).mount('#app')