178 lines
5.8 KiB
Python
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,
|
|
}
|