# -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # 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}