Files

178 lines
5.8 KiB
Python

from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.content import SiteSetting
from app.models.security import AdminAuditLog, AdminIpBlacklist
from app.services.captcha import issue_captcha
from app.services.login_guard import (
LOGIN_LOCK_MINUTES_KEY,
LOGIN_MAX_FAILURES_KEY,
read_login_policy,
)
public_router = APIRouter()
router = APIRouter(dependencies=[Depends(require_staff)])
class LoginSecurityBody(BaseModel):
model_config = ConfigDict(strict=True)
loginMaxFailures: int
loginLockMinutes: int
class IpBlacklistBody(BaseModel):
model_config = ConfigDict(strict=True)
ip: str
remark: str = ""
def _naive(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _save_setting(db: Session, key: str, value: int) -> None:
row = db.scalar(select(SiteSetting).where(SiteSetting.key == key))
text = str(value)
if row is None:
db.add(SiteSetting(key=key, value=text))
else:
row.value = text
def _ip_item(row: AdminIpBlacklist) -> dict:
return {"id": row.id, "ip": row.ip, "remark": row.remark, "createdAt": _iso(row.created_at)}
def _audit_item(row: AdminAuditLog) -> dict:
return {
"id": row.id,
"userId": row.user_id,
"username": row.username,
"method": row.method,
"path": row.path,
"statusCode": row.status_code,
"ip": row.ip,
"createdAt": _iso(row.created_at),
}
@public_router.get("/auth/captcha")
def get_captcha() -> dict:
return issue_captcha()
@router.get("/login-security")
def get_login_security(db: Session = Depends(get_db)) -> dict:
failures, minutes = read_login_policy(db)
return {"loginMaxFailures": failures, "loginLockMinutes": minutes}
@router.put("/login-security")
def update_login_security(body: LoginSecurityBody, db: Session = Depends(get_db)) -> dict:
if body.loginMaxFailures <= 0 or body.loginLockMinutes <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须是正整数")
_save_setting(db, LOGIN_MAX_FAILURES_KEY, body.loginMaxFailures)
_save_setting(db, LOGIN_LOCK_MINUTES_KEY, body.loginLockMinutes)
try:
db.commit()
except IntegrityError:
db.rollback()
_save_setting(db, LOGIN_MAX_FAILURES_KEY, body.loginMaxFailures)
_save_setting(db, LOGIN_LOCK_MINUTES_KEY, body.loginLockMinutes)
db.commit()
failures, minutes = read_login_policy(db)
return {"loginMaxFailures": failures, "loginLockMinutes": minutes}
@router.get("/ip-blacklist")
def list_ip_blacklist(db: Session = Depends(get_db)) -> list[dict]:
rows = db.scalars(select(AdminIpBlacklist).order_by(AdminIpBlacklist.id.asc())).all()
return [_ip_item(row) for row in rows]
@router.post("/ip-blacklist")
def create_ip_blacklist(body: IpBlacklistBody, db: Session = Depends(get_db)) -> dict:
ip = body.ip.strip()
remark = body.remark.strip()
if not ip or len(ip) > 64 or any(char.isspace() for char in ip):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 无效")
if len(remark) > 255:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备注过长")
if db.scalar(select(AdminIpBlacklist.id).where(AdminIpBlacklist.ip == ip)) is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 已存在")
row = AdminIpBlacklist(ip=ip, remark=remark)
db.add(row)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 已存在")
db.refresh(row)
return _ip_item(row)
@router.delete("/ip-blacklist/{id}")
def delete_ip_blacklist(id: int, db: Session = Depends(get_db)) -> dict:
row = db.get(AdminIpBlacklist, id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
db.delete(row)
db.commit()
return {"id": id}
@router.get("/audit-logs")
def list_audit_logs(
current: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=200),
username: str | None = None,
startTime: datetime | None = None,
endTime: datetime | None = None,
db: Session = Depends(get_db),
) -> dict:
filters = []
if username and username.strip():
escaped = username.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
filters.append(AdminAuditLog.username.like(f"%{escaped}%", escape="\\"))
if startTime is not None:
filters.append(AdminAuditLog.created_at >= _naive(startTime))
if endTime is not None:
filters.append(AdminAuditLog.created_at <= _naive(endTime))
count_stmt = select(func.count()).select_from(AdminAuditLog)
list_stmt = select(AdminAuditLog)
if filters:
count_stmt = count_stmt.where(*filters)
list_stmt = list_stmt.where(*filters)
total = int(db.scalar(count_stmt) or 0)
rows = db.scalars(
list_stmt.order_by(AdminAuditLog.created_at.desc(), AdminAuditLog.id.desc())
.offset((current - 1) * size)
.limit(size)
).all()
return {
"records": [_audit_item(row) for row in rows],
"current": current,
"size": size,
"total": total,
}