增加API鉴权

This commit is contained in:
jeremygan2021
2025-11-16 18:00:28 +08:00
parent bb04bd8fa5
commit b7a8a86e53
23 changed files with 343 additions and 52 deletions

6
.env
View File

@@ -25,4 +25,8 @@ INK_HEIGHT=300
# 安全配置
SECRET_KEY=123tangledup-ai
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
ACCESS_TOKEN_EXPIRE_MINUTES=30
# 管理员配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=123456

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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)):

View File

@@ -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)
# 注册所有路由,并添加全局安全要求
api_router.include_router(devices.router, prefix="/devices")
api_router.include_router(contents.router, prefix="/contents")
api_router.include_router(todos.router, prefix="/todos")

View File

@@ -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,

View File

@@ -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)):
"""
获取设备状态

View File

@@ -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)):
"""
标记待办事项为未完成

118
auth.py Normal file
View File

@@ -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)

View File

@@ -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"

30
main.py
View File

@@ -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=["管理后台"])

View File

@@ -50,7 +50,22 @@
<button class="btn btn-outline-light me-2" id="themeToggle" title="切换主题">
<i class="fas fa-palette"></i>
</button>
<span class="navbar-text text-light">
<div class="dropdown">
<button class="btn btn-outline-light dropdown-toggle" type="button" id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-user-circle me-1"></i>
{% if request.session.get('username') %}
{{ request.session.get('username') }}
{% else %}
管理员
{% endif %}
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li><a class="dropdown-item" href="/admin/logout">
<i class="fas fa-sign-out-alt me-1"></i>登出
</a></li>
</ul>
</div>
<span class="navbar-text text-light ms-2">
<i class="far fa-clock me-1"></i>
<span id="currentTime"></span>
</span>

View File

@@ -0,0 +1,74 @@
{% extends "admin/base.html" %}
{% block title %}管理员登录{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>管理员登录</h2>
<p class="text-muted">墨水屏桌面屏幕系统</p>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<form method="post" action="/admin/login">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">记住我</label>
</div>
<button type="submit" class="btn btn-primary w-100">登录</button>
</form>
</div>
</div>
<style>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f8f9fa;
}
.login-card {
background: white;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 2rem;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h2 {
margin-bottom: 0.5rem;
color: #333;
}
.login-header p {
margin-bottom: 0;
color: #6c757d;
font-size: 0.9rem;
}
</style>
{% endblock %}