diff --git a/.env b/.env index fdaeb34..c64ef1e 100644 --- a/.env +++ b/.env @@ -25,4 +25,8 @@ INK_HEIGHT=300 # 安全配置 SECRET_KEY=123tangledup-ai ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=30 \ No newline at end of file +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# 管理员配置 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=123456 \ No newline at end of file diff --git a/__pycache__/admin_routes.cpython-312.pyc b/__pycache__/admin_routes.cpython-312.pyc index 703d679..aeaab72 100644 Binary files a/__pycache__/admin_routes.cpython-312.pyc and b/__pycache__/admin_routes.cpython-312.pyc differ diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..e23d6cc Binary files /dev/null and b/__pycache__/auth.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index ec3cb40..2e2a64f 100644 Binary files a/__pycache__/config.cpython-312.pyc and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index bf736e9..634ab37 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index be9a38d..64213cd 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 3ed9b86..12cfa21 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/mqtt_manager.cpython-312.pyc b/__pycache__/mqtt_manager.cpython-312.pyc index 0da7ebb..6257174 100644 Binary files a/__pycache__/mqtt_manager.cpython-312.pyc and b/__pycache__/mqtt_manager.cpython-312.pyc differ diff --git a/__pycache__/schemas.cpython-312.pyc b/__pycache__/schemas.cpython-312.pyc index f4433b5..c5510fb 100644 Binary files a/__pycache__/schemas.cpython-312.pyc and b/__pycache__/schemas.cpython-312.pyc differ diff --git a/admin_routes.py b/admin_routes.py index 8f324cd..68b0a9a 100644 --- a/admin_routes.py +++ b/admin_routes.py @@ -7,6 +7,7 @@ import json import os import secrets from datetime import datetime +from config import settings from database import get_db from models import Device as DeviceModel, Content as ContentModel, Todo as TodoModel @@ -20,6 +21,68 @@ templates = Jinja2Templates(directory="templates") # 创建管理后台路由 admin_router = APIRouter() +# 登录页面 +@admin_router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, next: Optional[str] = None): + """ + 管理员登录页面 + """ + # 如果已经登录,重定向到首页 + if request.session.get("authenticated"): + return RedirectResponse(url=next or "/admin/", status_code=303) + + return templates.TemplateResponse("admin/login.html", { + "request": request, + "next": next + }) + +# 登录处理 +@admin_router.post("/login", response_class=HTMLResponse) +async def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + remember: Optional[bool] = Form(False), + next: Optional[str] = None +): + """ + 处理管理员登录 + """ + # 验证用户名和密码 + # 这里使用配置文件中的设置,实际项目中应该使用数据库存储用户信息 + if username == settings.admin_username and password == settings.admin_password: + # 设置会话 + request.session["authenticated"] = True + request.session["username"] = username + + # 设置会话过期时间 + if remember: + request.session["expire_at_browser_close"] = False + else: + request.session["expire_at_browser_close"] = True + + # 重定向到原始请求的页面或首页 + return RedirectResponse(url=next or "/admin/", status_code=303) + else: + # 登录失败,返回错误信息 + return templates.TemplateResponse("admin/login.html", { + "request": request, + "next": next, + "error": "用户名或密码错误" + }) + +# 登出 +@admin_router.get("/logout", response_class=HTMLResponse) +async def logout(request: Request): + """ + 管理员登出 + """ + # 清除会话 + request.session.clear() + + # 重定向到登录页面 + return RedirectResponse(url="/admin/login", status_code=303) + # 管理后台路由 @admin_router.get("/", response_class=HTMLResponse) async def admin_dashboard(request: Request, db: Session = Depends(get_db)): diff --git a/api/__init__.py b/api/__init__.py index abb40a1..ed9a309 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -3,7 +3,7 @@ from api import devices, contents, todos api_router = APIRouter() -# 注册所有路由 -api_router.include_router(devices.router) -api_router.include_router(contents.router) -api_router.include_router(todos.router) \ No newline at end of file +# 注册所有路由,并添加全局安全要求 +api_router.include_router(devices.router, prefix="/devices") +api_router.include_router(contents.router, prefix="/contents") +api_router.include_router(todos.router, prefix="/todos") \ No newline at end of file diff --git a/api/__pycache__/__init__.cpython-312.pyc b/api/__pycache__/__init__.cpython-312.pyc index 6178234..4cf179d 100644 Binary files a/api/__pycache__/__init__.cpython-312.pyc and b/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/__pycache__/contents.cpython-312.pyc b/api/__pycache__/contents.cpython-312.pyc index cc16a33..2942577 100644 Binary files a/api/__pycache__/contents.cpython-312.pyc and b/api/__pycache__/contents.cpython-312.pyc differ diff --git a/api/__pycache__/devices.cpython-312.pyc b/api/__pycache__/devices.cpython-312.pyc index 32f8433..f6f19fd 100644 Binary files a/api/__pycache__/devices.cpython-312.pyc and b/api/__pycache__/devices.cpython-312.pyc differ diff --git a/api/__pycache__/todos.cpython-312.pyc b/api/__pycache__/todos.cpython-312.pyc index dc425f4..23c30f5 100644 Binary files a/api/__pycache__/todos.cpython-312.pyc and b/api/__pycache__/todos.cpython-312.pyc differ diff --git a/api/contents.py b/api/contents.py index 1c6d97a..7b7af55 100644 --- a/api/contents.py +++ b/api/contents.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Query, File, UploadFile +from fastapi import APIRouter, Depends, HTTPException, status, Query, File, UploadFile, Security from sqlalchemy.orm import Session from sqlalchemy import func from typing import List, Optional @@ -11,13 +11,13 @@ from models import Content as ContentModel, Device as DeviceModel from mqtt_manager import mqtt_manager from image_processor import image_processor from config import settings +from auth import get_api_key router = APIRouter( - prefix="/api", tags=["contents"] ) -@router.post("/devices/{device_id}/content", response_model=ContentSchema, status_code=status.HTTP_201_CREATED) +@router.post("/devices/{device_id}/content", response_model=ContentSchema, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_api_key)]) async def create_content( device_id: str, content: ContentCreate, @@ -60,7 +60,7 @@ async def create_content( return db_content -@router.get("/devices/{device_id}/content", response_model=List[ContentSchema]) +@router.get("/devices/{device_id}/content", response_model=List[ContentSchema], dependencies=[Depends(get_api_key)]) async def list_content( device_id: str, skip: int = 0, @@ -87,7 +87,7 @@ async def list_content( contents = query.order_by(ContentModel.version.desc()).offset(skip).limit(limit).all() return contents -@router.get("/devices/{device_id}/content/{version}", response_model=ContentResponse) +@router.get("/devices/{device_id}/content/{version}", response_model=ContentResponse, dependencies=[Depends(get_api_key)]) async def get_content( device_id: str, version: int, @@ -141,7 +141,7 @@ async def get_content( created_at=content.created_at ) -@router.put("/devices/{device_id}/content/{version}", response_model=ContentSchema) +@router.put("/devices/{device_id}/content/{version}", response_model=ContentSchema, dependencies=[Depends(get_api_key)]) async def update_content( device_id: str, version: int, @@ -176,7 +176,7 @@ async def update_content( return content -@router.delete("/devices/{device_id}/content/{version}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/devices/{device_id}/content/{version}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_api_key)]) async def delete_content( device_id: str, version: int, diff --git a/api/devices.py b/api/devices.py index c05985a..adc339c 100644 --- a/api/devices.py +++ b/api/devices.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Security from sqlalchemy.orm import Session from typing import List, Optional from datetime import datetime @@ -9,13 +9,13 @@ from schemas import Device as DeviceSchema, DeviceCreate, DeviceUpdate, Bootstra from models import Device as DeviceModel from database import Content as ContentModel from mqtt_manager import mqtt_manager +from auth import get_api_key router = APIRouter( - prefix="/api/devices", tags=["devices"] ) -@router.post("/", response_model=DeviceSchema, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=DeviceSchema, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_api_key)]) async def create_device(device: DeviceCreate, db: Session = Depends(get_db)): """ 注册新设备 @@ -46,7 +46,7 @@ async def create_device(device: DeviceCreate, db: Session = Depends(get_db)): return db_device -@router.get("/", response_model=List[DeviceSchema]) +@router.get("/", response_model=List[DeviceSchema], dependencies=[Depends(get_api_key)]) async def list_devices( skip: int = 0, limit: int = 100, @@ -68,7 +68,7 @@ async def list_devices( devices = query.offset(skip).limit(limit).all() return devices -@router.get("/{device_id}", response_model=DeviceSchema) +@router.get("/{device_id}", response_model=DeviceSchema, dependencies=[Depends(get_api_key)]) async def get_device(device_id: str, db: Session = Depends(get_db)): """ 获取设备详情 @@ -81,7 +81,7 @@ async def get_device(device_id: str, db: Session = Depends(get_db)): ) return device -@router.put("/{device_id}", response_model=DeviceSchema) +@router.put("/{device_id}", response_model=DeviceSchema, dependencies=[Depends(get_api_key)]) async def update_device( device_id: str, device_update: DeviceUpdate, @@ -108,7 +108,7 @@ async def update_device( return device -@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_api_key)]) async def delete_device(device_id: str, db: Session = Depends(get_db)): """ 删除设备 @@ -126,11 +126,12 @@ async def delete_device(device_id: str, db: Session = Depends(get_db)): db.delete(device) db.commit() -@router.get("/{device_id}/bootstrap", response_model=BootstrapResponse) -async def device_bootstrap(device_id: str, db: Session = Depends(get_db)): +@router.post("/{device_id}/bootstrap", response_model=BootstrapResponse, dependencies=[Depends(get_api_key)]) +async def bootstrap_device(device_id: str, db: Session = Depends(get_db)): """ - 设备启动获取当前版本信息 + 设备引导 - 获取设备配置和内容 """ + # 验证设备是否存在 device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() if not device: raise HTTPException( @@ -138,29 +139,19 @@ async def device_bootstrap(device_id: str, db: Session = Depends(get_db)): detail="设备不存在" ) - # 更新设备最后在线时间 - device.last_online = datetime.utcnow() - db.commit() - - # 获取最新的活跃内容 - latest_content = db.query(ContentModel).filter( - ContentModel.device_id == device_id, - ContentModel.is_active == True - ).order_by(ContentModel.version.desc()).first() + # 获取设备场景的内容 + contents = db.query(ContentModel).filter(ContentModel.scene == device.scene).all() + # 构建响应 response = BootstrapResponse( device_id=device_id, - timezone=latest_content.timezone if latest_content else "Asia/Shanghai", - time_format=latest_content.time_format if latest_content else "%Y-%m-%d %H:%M" + scene=device.scene, + contents=contents ) - if latest_content: - response.content_version = latest_content.version - response.last_updated = latest_content.created_at - return response -@router.get("/{device_id}/status") +@router.get("/{device_id}/status", dependencies=[Depends(get_api_key)]) async def get_device_status(device_id: str, db: Session = Depends(get_db)): """ 获取设备状态 diff --git a/api/todos.py b/api/todos.py index fbeb654..f198a7e 100644 --- a/api/todos.py +++ b/api/todos.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Security from sqlalchemy.orm import Session from typing import List, Optional from datetime import datetime @@ -7,13 +7,13 @@ from database import get_db from schemas import Todo as TodoSchema, TodoCreate, TodoUpdate from models import Todo as TodoModel from database import Device as DeviceModel +from auth import get_api_key router = APIRouter( - prefix="/api/todos", tags=["todos"] ) -@router.post("/", response_model=TodoSchema, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=TodoSchema, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_api_key)]) async def create_todo(todo: TodoCreate, db: Session = Depends(get_db)): """ 创建新的待办事项 @@ -40,7 +40,7 @@ async def create_todo(todo: TodoCreate, db: Session = Depends(get_db)): return db_todo -@router.get("/", response_model=List[TodoSchema]) +@router.get("/", response_model=List[TodoSchema], dependencies=[Depends(get_api_key)]) async def list_todos( skip: int = 0, limit: int = 100, @@ -62,7 +62,7 @@ async def list_todos( todos = query.order_by(TodoModel.created_at.desc()).offset(skip).limit(limit).all() return todos -@router.get("/{todo_id}", response_model=TodoSchema) +@router.get("/{todo_id}", response_model=TodoSchema, dependencies=[Depends(get_api_key)]) async def get_todo(todo_id: int, db: Session = Depends(get_db)): """ 获取待办事项详情 @@ -75,7 +75,7 @@ async def get_todo(todo_id: int, db: Session = Depends(get_db)): ) return todo -@router.put("/{todo_id}", response_model=TodoSchema) +@router.put("/{todo_id}", response_model=TodoSchema, dependencies=[Depends(get_api_key)]) async def update_todo( todo_id: int, todo_update: TodoUpdate, @@ -110,7 +110,7 @@ async def update_todo( return todo -@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_api_key)]) async def delete_todo(todo_id: int, db: Session = Depends(get_db)): """ 删除待办事项 @@ -125,7 +125,7 @@ async def delete_todo(todo_id: int, db: Session = Depends(get_db)): db.delete(todo) db.commit() -@router.post("/{todo_id}/complete", response_model=TodoSchema) +@router.post("/{todo_id}/complete", response_model=TodoSchema, dependencies=[Depends(get_api_key)]) async def complete_todo(todo_id: int, db: Session = Depends(get_db)): """ 标记待办事项为完成 @@ -146,7 +146,7 @@ async def complete_todo(todo_id: int, db: Session = Depends(get_db)): return todo -@router.post("/{todo_id}/incomplete", response_model=TodoSchema) +@router.post("/{todo_id}/incomplete", response_model=TodoSchema, dependencies=[Depends(get_api_key)]) async def incomplete_todo(todo_id: int, db: Session = Depends(get_db)): """ 标记待办事项为未完成 diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..fe8bf4d --- /dev/null +++ b/auth.py @@ -0,0 +1,118 @@ +from fastapi import HTTPException, status, Request, Security, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from config import settings +import logging + +logger = logging.getLogger(__name__) + +# 创建API Key安全方案 +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +async def get_api_key(api_key: str = Security(api_key_header)): + """ + API Key依赖项,用于路由级别的鉴权 + """ + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="缺少API Key", + headers={"WWW-Authenticate": "ApiKey"}, + ) + + if api_key != settings.secret_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的API Key", + headers={"WWW-Authenticate": "ApiKey"}, + ) + + return api_key + +class APIKeyMiddleware(BaseHTTPMiddleware): + """ + API Key鉴权中间件 + 验证请求中的Secret Key + """ + + async def dispatch(self, request: Request, call_next): + # 跳过不需要鉴权的路径 + if self._should_skip_auth(request.url.path): + return await call_next(request) + + # 检查API Key + api_key = request.headers.get("X-API-Key") + if not api_key: + logger.warning(f"缺少API Key: {request.method} {request.url.path}") + return Response( + content='{"detail": "缺少API Key"}', + status_code=status.HTTP_401_UNAUTHORIZED, + media_type="application/json" + ) + + # 验证API Key + if api_key != settings.secret_key: + logger.warning(f"无效的API Key: {request.method} {request.url.path}") + return Response( + content='{"detail": "无效的API Key"}', + status_code=status.HTTP_401_UNAUTHORIZED, + media_type="application/json" + ) + + return await call_next(request) + + def _should_skip_auth(self, path: str) -> bool: + """ + 判断是否跳过鉴权的路径 + """ + # 所有API路径都需要鉴权,不跳过 + # 如果路径以/api开头,则不跳过(需要鉴权) + if path.startswith("/api"): + return False + + skip_paths = [ + "/", + "/health", + "/docs", + "/redoc", + "/openapi.json", + "/admin", + "/admin/login", + "/static", + ] + + # 检查是否以跳过路径开头 + for skip_path in skip_paths: + if path.startswith(skip_path): + return True + + return False + + +class AdminAuthMiddleware(BaseHTTPMiddleware): + """ + Admin页面认证中间件 + 验证用户是否已登录 + """ + + async def dispatch(self, request: Request, call_next): + # 只对admin路径进行认证 + if not request.url.path.startswith("/admin") or request.url.path == "/admin/login": + return await call_next(request) + + # 检查会话 + if not self._is_authenticated(request): + # 重定向到登录页面 + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/admin/login?next=" + request.url.path, status_code=303) + + return await call_next(request) + + def _is_authenticated(self, request: Request) -> bool: + """ + 检查用户是否已认证 + """ + # 从session中获取认证信息 + session = request.session + return session.get("authenticated", False) \ No newline at end of file diff --git a/config.py b/config.py index b69c719..6241588 100644 --- a/config.py +++ b/config.py @@ -29,6 +29,10 @@ class Settings(BaseSettings): algorithm: str = "HS256" access_token_expire_minutes: int = 30 + # 管理员配置 + admin_username: str = "admin" + admin_password: str = "123456" + class Config: env_file = ".env" diff --git a/main.py b/main.py index ebfbc88..5689b40 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from contextlib import asynccontextmanager import logging import os @@ -10,6 +11,7 @@ from database import init_db from mqtt_manager import mqtt_manager from api import api_router from admin_routes import admin_router +from auth import APIKeyMiddleware, AdminAuthMiddleware # 配置日志 logging.basicConfig( @@ -51,10 +53,21 @@ async def lifespan(app: FastAPI): # 创建FastAPI应用 app = FastAPI( - title=settings.app_name, - description="基于 FastAPI + MQTT + HTTP/HTTPS + NTP 的轻量级墨水屏显示系统服务端", + title="墨水屏桌面屏幕系统 API", + description="用于管理墨水屏设备、内容和待办事项的API", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, + openapi_components={ + "securitySchemes": { + "APIKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API Key鉴权,请在下方输入正确的API Key" + } + } + }, + security=[{"APIKeyHeader": []}] ) # 添加CORS中间件 @@ -66,11 +79,20 @@ app.add_middleware( allow_headers=["*"], ) +# 添加API Key鉴权中间件 +app.add_middleware(APIKeyMiddleware) + +# 添加Admin认证中间件 +app.add_middleware(AdminAuthMiddleware) + +# 添加Session中间件 +app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) + # 挂载静态文件 app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") # 注册API路由 -app.include_router(api_router) +app.include_router(api_router, prefix="/api") # 包含管理后台路由 app.include_router(admin_router, prefix="/admin", tags=["管理后台"]) diff --git a/templates/admin/base.html b/templates/admin/base.html index 06a0394..5b8e4f5 100644 --- a/templates/admin/base.html +++ b/templates/admin/base.html @@ -50,7 +50,22 @@ - + + diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..174ab04 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,74 @@ +{% extends "admin/base.html" %} + +{% block title %}管理员登录{% endblock %} + +{% block content %} +
+ +
+ + +{% endblock %} \ No newline at end of file