在上游 MediaCrawler 之上新增一层: - 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、 企业微信通知。每轮采集写入独立目录,差分才成立。 - WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。 WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。 - 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」, 未接通的平台直接拒绝建任务,而不是静默跑空。 - 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4 (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库, 连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。 修复上游缺陷: - xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到, 而那份资料只喂给一个空函数。改为尽力而为,失败不中断。 - xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。 新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。 - requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、 这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。 改动过的上游文件清单及合并方式见 UPSTREAM.md。 测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关)
174 lines
6.1 KiB
Python
174 lines
6.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2025 [email protected]
|
|
#
|
|
# This file is part of MediaCrawler project.
|
|
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/auth.py
|
|
# GitHub: https://github.com/NanmiCoder
|
|
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
|
|
#
|
|
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
|
|
# 1. 不得用于任何商业用途。
|
|
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
|
|
# 3. 不得进行大规模爬取或对平台造成运营干扰。
|
|
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
|
|
# 5. 不得用于任何非法或不当的用途。
|
|
#
|
|
# 详细许可条款请参阅项目根目录下的LICENSE文件。
|
|
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
|
|
|
|
"""Login / logout endpoints.
|
|
|
|
Deliberately exempt from ``require_auth``:
|
|
* ``/login`` -- it is the way in.
|
|
* ``/logout`` -- exempt so an already-expired session still gets a clean 200
|
|
and a cleared cookie instead of a confusing 401, which
|
|
would leave the browser holding a stale cookie.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
|
|
from ..auth import (
|
|
INVALID_CREDENTIALS,
|
|
SESSION_COOKIE_NAME,
|
|
check_password,
|
|
require_auth,
|
|
clear_failures,
|
|
client_key,
|
|
cookie_secure,
|
|
create_session,
|
|
purge_expired_sessions,
|
|
record_failure,
|
|
resolve_session,
|
|
retry_after_seconds,
|
|
revoke_all_sessions,
|
|
revoke_session,
|
|
set_password,
|
|
token_from_request,
|
|
)
|
|
from ..monitor.db import get_session
|
|
from ..schemas.auth import ChangePasswordPayload, LoginPayload
|
|
from tools.time_util import get_current_timestamp
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
def _apply_session_cookie(response: Response, token: str, expires_at: int) -> None:
|
|
"""Attach the session cookie.
|
|
|
|
``secure`` is off by default because the panel is served over plain HTTP on
|
|
a LAN; setting it there means the browser silently discards the cookie and
|
|
the login page just loops with no error. ``SameSite=lax`` is also what
|
|
blocks cross-site POSTs, i.e. the CSRF defence for the write endpoints.
|
|
"""
|
|
max_age = max((expires_at - get_current_timestamp()) // 1000, 60)
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=token,
|
|
max_age=max_age,
|
|
httponly=True,
|
|
secure=cookie_secure(),
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(payload: LoginPayload, request: Request, response: Response):
|
|
key = client_key(request)
|
|
|
|
wait = await retry_after_seconds(key)
|
|
if wait:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail=f"尝试过于频繁,请 {wait} 秒后再试",
|
|
headers={"Retry-After": str(wait)},
|
|
)
|
|
|
|
async with get_session() as session:
|
|
valid = await check_password(session, payload.password)
|
|
token = ""
|
|
expires_at = 0
|
|
if valid:
|
|
await purge_expired_sessions(session)
|
|
token, expires_at = await create_session(session)
|
|
|
|
if not valid:
|
|
await record_failure(key)
|
|
# One generic message regardless of whether the password was wrong,
|
|
# empty, or simply not set yet -- no oracle.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS
|
|
)
|
|
|
|
await clear_failures(key)
|
|
_apply_session_cookie(response, token, expires_at)
|
|
return {"expires_at": expires_at}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request, response: Response):
|
|
token = token_from_request(request)
|
|
if token:
|
|
async with get_session() as session:
|
|
await revoke_session(session, token)
|
|
|
|
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
|
return {"message": "已退出登录"}
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(request: Request):
|
|
"""Identity probe. The SPA treats a 401 here as "show the login page".
|
|
|
|
Does its own resolution rather than using ``require_auth`` so it can also
|
|
report the expiry.
|
|
"""
|
|
token = token_from_request(request)
|
|
if not token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS
|
|
)
|
|
|
|
async with get_session() as session:
|
|
row = await resolve_session(session, token)
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS
|
|
)
|
|
return {"authenticated": True, "expires_at": row.expires_at}
|
|
|
|
|
|
# Auth as a route dependency, not only inside the handler: FastAPI validates the
|
|
# request body before the endpoint body runs, so an unauthenticated caller would
|
|
# otherwise get a 422 that confirms the endpoint and its schema exist.
|
|
@router.post("/password", dependencies=[Depends(require_auth)])
|
|
async def change_password(
|
|
payload: ChangePasswordPayload, request: Request, response: Response
|
|
):
|
|
"""Change the password and log every device out.
|
|
|
|
Revoking all sessions is the point: a password change is usually a response
|
|
to suspicion, and leaving other sessions alive would defeat it.
|
|
"""
|
|
token = token_from_request(request)
|
|
|
|
async with get_session() as session:
|
|
current = await resolve_session(session, token)
|
|
if current is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS
|
|
)
|
|
|
|
if not await check_password(session, payload.current):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="当前密码不正确"
|
|
)
|
|
|
|
await set_password(session, payload.new)
|
|
await revoke_all_sessions(session)
|
|
# Issue a fresh session so the caller is not bounced mid-use.
|
|
new_token, expires_at = await create_session(session)
|
|
|
|
_apply_session_cookie(response, new_token, expires_at)
|
|
return {"message": "密码已更新,其他设备的登录已全部失效", "expires_at": expires_at}
|