tmwgsicp-wechat-download-api/routes/login.py

600 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (C) 2026 tmwgsicp
# Licensed under the GNU Affero General Public License v3.0
# See LICENSE file in the project root for full license text.
# SPDX-License-Identifier: AGPL-3.0-only
"""
登录路由 - FastAPI版本
实现真实的微信公众号登录流程
"""
from fastapi import APIRouter, HTTPException, Response, Request
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
from typing import Optional, Dict
import httpx
import time
from utils.auth_manager import auth_manager
from utils.webhook import webhook
router = APIRouter()
# 微信登录API端点
MP_BASE_URL = "https://mp.weixin.qq.com"
QR_ENDPOINT = f"{MP_BASE_URL}/cgi-bin/scanloginqrcode"
BIZ_LOGIN_ENDPOINT = f"{MP_BASE_URL}/cgi-bin/bizlogin"
# 全局session状态存储
_sessions = {}
async def proxy_wx_request(request: Request, url: str, params: dict = None, method: str = "GET", data: dict = None):
"""
代理微信请求,转发浏览器cookies
这个函数类似Node.js版本的proxyMpRequest:
1. 从浏览器请求中提取cookies
2. 转发给微信API
3. 把微信的Set-Cookie响应转发回浏览器
"""
# 从浏览器请求中提取cookies
cookie_header = request.headers.get("cookie", "")
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://mp.weixin.qq.com/",
"Origin": "https://mp.weixin.qq.com",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Cookie": cookie_header # 转发浏览器的cookies
}
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
if method == "GET":
response = await client.get(url, params=params, headers=headers)
else:
response = await client.post(url, params=params, data=data, headers=headers)
return response
class LoginRequest(BaseModel):
"""手动登录请求模型"""
token: str
cookie: str
fakeid: str
nickname: str
expire_time: int
class LoginResponse(BaseModel):
"""登录响应模型"""
success: bool
message: str
@router.post("/session/{sessionid}", summary="初始化登录会话", include_in_schema=True)
async def create_session(sessionid: str, request: Request):
"""
初始化登录会话,必须在获取二维码之前调用。
**路径参数:**
- **sessionid**: 会话标识,由前端生成
"""
try:
# 🔍 调试:输出请求信息
cookie_header = request.headers.get("cookie", "")
print(f"🔍 [DEBUG] 创建Session - Cookie: {cookie_header[:100]}..." if len(cookie_header) > 100 else f"🔍 [DEBUG] 创建Session - Cookie: {cookie_header}")
# ⭐ 关键调用bizlogin而不是scanloginqrcode
body = {
"userlang": "zh_CN",
"redirect_url": "",
"login_type": 3,
"sessionid": sessionid,
"token": "",
"lang": "zh_CN",
"f": "json",
"ajax": 1
}
response = await proxy_wx_request(
request,
BIZ_LOGIN_ENDPOINT, # ⭐ 使用bizlogin
params={"action": "startlogin"},
method="POST",
data=body # ⭐ 传递body
)
# 存储session
_sessions[sessionid] = {
"created_at": time.time(),
"status": "created"
}
data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {"base_resp": {"ret": 0}}
# 🔍 调试:输出响应详情
print(f"🔍 [DEBUG] Session响应状态码: {response.status_code}")
print(f"🔍 [DEBUG] Session响应数据: {data}")
print(f"🔍 [DEBUG] Session响应 Set-Cookie 数量: {len(response.headers.get_list('set-cookie'))}")
for i, cookie in enumerate(response.headers.get_list("set-cookie")):
print(f"🔍 [DEBUG] Cookie [{i}]: {cookie[:150]}..." if len(cookie) > 150 else f"🔍 [DEBUG] Cookie [{i}]: {cookie}")
# 转发Set-Cookie智能处理Secure标志
response_obj = JSONResponse(content=data)
# 🔍 检测是否使用 HTTPS支持反向代理
is_https = (
request.url.scheme == "https" or
request.headers.get("x-forwarded-proto") == "https" or
request.headers.get("x-forwarded-ssl") == "on"
)
if is_https:
print(f"🔒 检测到HTTPS环境Cookie将保留Secure标志安全传输")
else:
print(f"⚠️ 检测到HTTP环境Cookie将移除Secure标志兼容模式生产环境建议使用HTTPS")
for cookie_str in response.headers.get_list("set-cookie"):
if not is_https:
# 🔧 HTTP模式移除Secure标志以支持HTTP传输
modified_cookie = cookie_str.replace("; Secure", "")
response_obj.headers.append("Set-Cookie", modified_cookie)
else:
# 🔒 HTTPS模式保留Secure标志保持安全性
response_obj.headers.append("Set-Cookie", cookie_str)
print(f"✅ 创建session: {sessionid}, 响应: {data}")
return response_obj
except Exception as e:
print(f"❌ 创建session失败: {str(e)}")
import traceback
traceback.print_exc()
return JSONResponse(content={"base_resp": {"ret": 0}}) # 返回成功避免前端报错
@router.get("/getqrcode", summary="获取登录二维码", include_in_schema=True)
async def get_qrcode(request: Request):
"""
获取微信公众号登录二维码图片,用微信扫码登录。
**返回:** 二维码图片PNG/JPEG 格式)
"""
try:
# 🔍 调试:输出请求信息
cookie_header = request.headers.get("cookie", "")
print(f"🔍 [DEBUG] 二维码请求 Cookie: {cookie_header[:100]}..." if len(cookie_header) > 100 else f"🔍 [DEBUG] 二维码请求 Cookie: {cookie_header}")
# 代理请求到微信
response = await proxy_wx_request(
request,
QR_ENDPOINT,
params={
"action": "getqrcode",
"random": int(time.time() * 1000)
}
)
# 🔍 调试:输出响应信息
print(f"🔍 [DEBUG] 微信响应状态码: {response.status_code}")
print(f"🔍 [DEBUG] 微信响应 Content-Type: {response.headers.get('content-type', 'N/A')}")
print(f"🔍 [DEBUG] 微信响应内容长度: {len(response.content)} 字节")
print(f"🔍 [DEBUG] 微信响应 Set-Cookie: {response.headers.get('set-cookie', 'N/A')}")
# 检查响应类型
content_type = response.headers.get("content-type", "")
content = response.content
# 检查是否是图片格式
is_png = content.startswith(b'\x89PNG')
is_jpeg = content.startswith(b'\xff\xd8\xff') or b'JFIF' in content[:20]
is_image = "image" in content_type or is_png or is_jpeg
# 如果返回的是JSON或者不是图片说明出错了
if not is_image:
try:
error_data = response.json()
print(f"⚠️ 二维码接口返回JSON: {error_data}")
return JSONResponse(
status_code=400,
content={"error": "需要先调用 /session/{sessionid} 创建会话", "detail": error_data}
)
except:
print(f"⚠️ 二维码接口返回非图片内容: {content_type}")
print(f"响应内容前20字节: {content[:20]}")
return JSONResponse(
status_code=400,
content={"error": "获取二维码失败,响应格式异常"}
)
# 确定正确的媒体类型
if is_png:
media_type = "image/png"
print(f"✅ 获取到PNG格式二维码")
elif is_jpeg:
media_type = "image/jpeg"
print(f"✅ 获取到JPEG格式二维码")
else:
# 使用响应头中的类型
media_type = content_type if "image" in content_type else "image/png"
print(f"✅ 获取到二维码,类型: {media_type}")
# 可选:保存二维码到本地(用于调试)
import os
qrcode_dir = "static/qrcodes"
if not os.path.exists(qrcode_dir):
os.makedirs(qrcode_dir)
# 根据格式确定文件扩展名
ext = "png" if is_png else "jpg"
qrcode_path = f"{qrcode_dir}/login_qrcode.{ext}"
with open(qrcode_path, "wb") as f:
f.write(content)
print(f"💾 二维码已保存到: {qrcode_path}")
# 构建响应,转发Set-Cookie
response_obj = Response(
content=content,
media_type=media_type,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
# 转发Set-Cookie到浏览器智能处理Secure标志
is_https = (
request.url.scheme == "https" or
request.headers.get("x-forwarded-proto") == "https" or
request.headers.get("x-forwarded-ssl") == "on"
)
for cookie_str in response.headers.get_list("set-cookie"):
if not is_https:
# 🔧 HTTP模式移除Secure标志
modified_cookie = cookie_str.replace("; Secure", "")
response_obj.headers.append("Set-Cookie", modified_cookie)
else:
# 🔒 HTTPS模式保留Secure标志
response_obj.headers.append("Set-Cookie", cookie_str)
return response_obj
except httpx.HTTPStatusError as e:
print(f"❌ HTTP错误: {e.response.status_code}, 内容: {e.response.text[:200]}")
raise HTTPException(
status_code=e.response.status_code,
detail=f"获取二维码失败: {e.response.status_code}"
)
except Exception as e:
print(f"❌ 获取二维码异常: {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"获取二维码失败: {str(e)}")
@router.get("/scan", summary="检查扫码状态", include_in_schema=True)
async def check_scan_status(request: Request):
"""
轮询检查二维码扫描状态。
**返回状态:** 等待扫码 / 已扫码待确认 / 确认成功 / 二维码过期
"""
try:
# 代理请求到微信
response = await proxy_wx_request(
request,
QR_ENDPOINT,
params={
"action": "ask",
"token": "",
"lang": "zh_CN",
"f": "json",
"ajax": 1
}
)
response.raise_for_status()
# 返回微信的响应
data = response.json()
# 记录状态用于调试
if data.get("base_resp", {}).get("ret") != 0:
print(f"⚠️ 扫码状态检查失败: ret={data.get('base_resp', {}).get('ret')}")
else:
status = data.get("status", 0)
if status == 1: # 登录成功
print(f"🎉 用户已确认登录! status=1")
elif status in [4, 6]: # 已扫码
acct_size = data.get("acct_size", 0)
print(f"✅ 用户已扫码, status={status}, acct_size={acct_size}")
# 转发Set-Cookie到浏览器智能处理Secure标志
response_obj = JSONResponse(content=data)
is_https = (
request.url.scheme == "https" or
request.headers.get("x-forwarded-proto") == "https" or
request.headers.get("x-forwarded-ssl") == "on"
)
for cookie_str in response.headers.get_list("set-cookie"):
if not is_https:
# 🔧 HTTP模式移除Secure标志
modified_cookie = cookie_str.replace("; Secure", "")
response_obj.headers.append("Set-Cookie", modified_cookie)
else:
# 🔒 HTTPS模式保留Secure标志
response_obj.headers.append("Set-Cookie", cookie_str)
return response_obj
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"检查扫码状态失败: {e.response.status_code}"
)
except Exception as e:
print(f"❌ 检查扫码状态异常: {str(e)}")
raise HTTPException(status_code=500, detail=f"检查扫码状态失败: {str(e)}")
@router.post("/bizlogin", summary="完成登录", include_in_schema=True)
async def biz_login(request: Request):
"""
扫码确认后调用此接口完成登录,成功后凭证自动保存到 `.env`。
**返回:** Token、Cookie、FakeID、昵称等登录凭证
"""
try:
# 准备登录请求数据
login_data = {
"userlang": "zh_CN",
"redirect_url": "",
"cookie_forbidden": 0,
"cookie_cleaned": 0,
"plugin_used": 0,
"login_type": 3,
"token": "",
"lang": "zh_CN",
"f": "json",
"ajax": 1
}
# 发起登录请求
response = await proxy_wx_request(
request,
BIZ_LOGIN_ENDPOINT,
params={"action": "login"},
method="POST",
data=login_data # ⭐ 修复变量名
)
response.raise_for_status()
# 解析响应
result = response.json()
print(f"📍 Bizlogin响应: base_resp.ret={result.get('base_resp', {}).get('ret')}")
# 检查登录是否成功
if result.get("base_resp", {}).get("ret") != 0:
error_msg = result.get("base_resp", {}).get("err_msg", "登录失败")
print(f"❌ 微信返回错误: {error_msg}")
return JSONResponse(
status_code=400,
content={"success": False, "error": error_msg}
)
# 获取redirect_url中的token
redirect_url = result.get("redirect_url", "")
if not redirect_url:
print(f"❌ 未获取到redirect_url完整响应: {result}")
return JSONResponse(
status_code=400,
content={"success": False, "error": "未获取到登录凭证"}
)
# 从URL中提取token
from urllib.parse import urlparse, parse_qs
parsed = urlparse(f"http://localhost{redirect_url}")
token = parse_qs(parsed.query).get("token", [""])[0]
if not token:
print(f"❌ 未获取到Tokenredirect_url: {redirect_url}")
return JSONResponse(
status_code=400,
content={"success": False, "error": "未获取到Token"}
)
# 获取Cookie合并浏览器已有的cookie和bizlogin响应新设的cookie
cookies = {}
# 先解析浏览器在整个登录流程中累积的cookie
browser_cookie = request.headers.get("cookie", "")
for part in browser_cookie.split(";"):
part = part.strip()
if "=" in part:
key, _, value = part.partition("=")
cookies[key.strip()] = value.strip()
# 再用bizlogin响应中新设的cookie覆盖这些是最新的
for cookie in response.cookies.jar:
cookies[cookie.name] = cookie.value
cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
# 获取公众号信息和FakeID使用同一个客户端
common_headers = {
"Cookie": cookie_str,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
nickname = "公众号"
fakeid = ""
async with httpx.AsyncClient(timeout=30.0) as client:
# 第一步:获取公众号昵称
info_response = await client.get(
f"{MP_BASE_URL}/cgi-bin/home",
params={"t": "home/index", "token": token, "lang": "zh_CN"},
headers=common_headers
)
html = info_response.text
import re
nick_match = re.search(r'nick_name\s*[:=]\s*["\']([^"\']+)["\']', html)
if nick_match:
nickname = nick_match.group(1)
# 第二步通过昵称搜索获取FakeID
print(f"🔍 开始获取FakeID昵称: {nickname}")
try:
search_response = await client.get(
f"{MP_BASE_URL}/cgi-bin/searchbiz",
params={
"action": "search_biz",
"token": token,
"lang": "zh_CN",
"f": "json",
"ajax": 1,
"random": time.time(),
"query": nickname,
"begin": 0,
"count": 5
},
headers=common_headers
)
print(f"📡 搜索API响应状态: {search_response.status_code}")
search_result = search_response.json()
print(f"📡 搜索结果: {search_result}")
if search_result.get("base_resp", {}).get("ret") == 0:
accounts = search_result.get("list", [])
print(f"📋 找到 {len(accounts)} 个公众号")
for account in accounts:
acc_nickname = account.get("nickname", "")
acc_fakeid = account.get("fakeid", "")
print(f" - {acc_nickname} (fakeid: {acc_fakeid})")
if acc_nickname == nickname:
fakeid = acc_fakeid
print(f"✅ 匹配成功FakeID: {fakeid}")
break
if not fakeid:
print(f"⚠️ 未找到完全匹配的公众号,尝试使用第一个结果")
if accounts:
fakeid = accounts[0].get("fakeid", "")
print(f"📝 使用第一个公众号的FakeID: {fakeid}")
else:
ret = search_result.get("base_resp", {}).get("ret")
err_msg = search_result.get("base_resp", {}).get("err_msg", "未知错误")
print(f"❌ 搜索API返回错误: ret={ret}, err_msg={err_msg}")
except Exception as e:
print(f"❌ 获取FakeID失败: {str(e)}")
import traceback
traceback.print_exc()
# 计算过期时间30天后
expire_time = int((time.time() + 30 * 24 * 3600) * 1000)
# 保存凭证
auth_manager.save_credentials(
token=token,
cookie=cookie_str,
fakeid=fakeid,
nickname=nickname,
expire_time=expire_time
)
print(f"✅ 登录成功: {nickname} (fakeid: {fakeid})")
print(f" Token: {token[:20]}...")
print(f" Cookie已保存到.env")
await webhook.notify('login_success', {
'nickname': nickname,
'fakeid': fakeid,
})
return {
"success": True,
"message": "登录成功",
"data": {
"nickname": nickname,
"fakeid": fakeid,
"token": token,
"expire_time": expire_time
}
}
except httpx.HTTPStatusError as e:
return JSONResponse(
status_code=e.response.status_code,
content={"success": False, "error": f"登录请求失败: {e.response.status_code}"}
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"success": False, "error": f"登录失败: {str(e)}"}
)
@router.post("/manual", response_model=LoginResponse, summary="手动配置登录凭证")
async def manual_login(request: LoginRequest):
"""
手动提交登录凭证(适用于已通过其他方式获取凭证的场景)。
**请求体参数:**
- **token** (必填): 微信 Token
- **cookie** (必填): 微信 Cookie
- **fakeid** (可选): 公众号 FakeID
- **nickname** (可选): 公众号昵称
- **expire_time** (可选): 过期时间戳
"""
try:
success = auth_manager.save_credentials(
token=request.token,
cookie=request.cookie,
fakeid=request.fakeid,
nickname=request.nickname,
expire_time=request.expire_time
)
if success:
await webhook.notify('login_success', {
'nickname': request.nickname or '',
'fakeid': request.fakeid or '',
})
return {
"success": True,
"message": "登录凭证已保存"
}
else:
return {
"success": False,
"message": "保存登录凭证失败"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
@router.get("/info", summary="获取登录信息")
async def get_login_info():
"""
获取当前登录用户的昵称、FakeID、过期时间等信息。
"""
credentials = auth_manager.get_credentials()
if credentials:
return {
"success": True,
"data": {
"nickname": credentials.get("nickname"),
"fakeid": credentials.get("fakeid"),
"expire_time": credentials.get("expire_time")
}
}
return {
"success": False,
"error": "未登录"
}