9c4908fb87
- /auth/login 全端口停用(403 引导手机号/小程序扫码登录) - 扫码会话接口下发 expires_in(前端二维码倒计时) - role_allowed 叠加制身份:所有账号自带 OPC 基础身份,operator/carrier 仅业务叠加,35 处 C 端端点统一放行
827 lines
36 KiB
Python
827 lines
36 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""认证路由:登录 / 注册 / 状态 / 校验 / 资料 / 令牌管理。
|
||
|
||
这是供 PineAgents 主后端 ``src/pineagents/app/routers/auth.py`` 转发调用的
|
||
"演示 FastAPI"(DEMO_API_BASE_URL,默认 http://127.0.0.1:8090)。
|
||
路由前缀 ``/auth``,字段与主后端转发模型完全一致,保证透传不丢字段。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import re
|
||
import secrets
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
from ... import config
|
||
from ..dependencies import get_current_user, get_db, extract_bearer_token
|
||
from ..schemas.auth import (
|
||
AuthStatusResponse,
|
||
LoginRequest,
|
||
LoginResponse,
|
||
PhoneLoginRequest,
|
||
ProfileResponse,
|
||
RegisterRequest,
|
||
RevokeTokenRequest,
|
||
SendCodeRequest,
|
||
SendCodeResponse,
|
||
UpdateProfileRequest,
|
||
VerifyResponse,
|
||
WxLoginRequest,
|
||
WxPhoneRequest,
|
||
MpQrConfirmRequest,
|
||
BindPhoneRequest,
|
||
UnbindRequest,
|
||
)
|
||
from ...infrastructure.repositories import Database
|
||
from ...services import sms, wechat
|
||
from ...domain.account_types import account_type, permission_role, label as account_type_label
|
||
|
||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||
|
||
# 扫码登录会话(内存 + TTL;生产可换 Redis)
|
||
wx_qr_store = wechat.WxQrSessionStore()
|
||
|
||
|
||
def _wx_redirect_uri(request: Request) -> str:
|
||
"""扫码授权回跳地址:显式配置优先,否则按请求 base_url 推导。"""
|
||
if config.WECHAT_QR_REDIRECT_URI:
|
||
return config.WECHAT_QR_REDIRECT_URI
|
||
return str(request.base_url).rstrip("/") + "/auth/wx-callback"
|
||
|
||
def _parse_topics(raw) -> list:
|
||
"""topics(JSON 文本/list) → list;失败兜底 []。"""
|
||
if not raw:
|
||
return []
|
||
if isinstance(raw, list):
|
||
return raw
|
||
if isinstance(raw, str):
|
||
try:
|
||
v = json.loads(raw)
|
||
return v if isinstance(v, list) else []
|
||
except (TypeError, ValueError):
|
||
return []
|
||
return []
|
||
|
||
|
||
# 用户来源(X-Client 头,回退 body.source;未指认则按 auth_type 推断)
|
||
_SOURCE_BY_CLIENT = {
|
||
"miniprogram": "mini_program", "mini_program": "mini_program", "mp": "mini_program",
|
||
"web": "web", "browser": "web",
|
||
"desktop": "desktop", "app": "desktop", "tauri": "desktop",
|
||
"park": "park", "admin": "admin", "enterprise": "enterprise",
|
||
"carrier": "carrier", "government": "government", "provider": "provider", "operator": "operator",
|
||
}
|
||
|
||
|
||
def _client_source(request: Request, body_source: str = "", auth_type: str = "") -> str:
|
||
"""确定用户来源:X-Client 头 > body.source > 按 auth_type 推断。"""
|
||
client = (request.headers.get("X-Client", "") or "").strip().lower()
|
||
if client in _SOURCE_BY_CLIENT:
|
||
return _SOURCE_BY_CLIENT[client]
|
||
if body_source in _SOURCE_BY_CLIENT.values():
|
||
return body_source
|
||
if body_source:
|
||
return body_source
|
||
if auth_type == "wx_openid":
|
||
return "wx"
|
||
if auth_type == "phone":
|
||
return "phone"
|
||
return "web"
|
||
|
||
|
||
def _client_ip(request: Request) -> str:
|
||
xff = request.headers.get("X-Forwarded-For", "")
|
||
if xff:
|
||
return xff.split(",")[0].strip()
|
||
return request.client.host if request.client else ""
|
||
|
||
# 账号统一使用手机号作为登录账号(11 位,1 开头)
|
||
_PHONE_RE = re.compile(r"^1\d{10}$")
|
||
|
||
|
||
def _is_phone(account: str) -> bool:
|
||
return bool(_PHONE_RE.match(account.strip()))
|
||
|
||
# 账户管理页可编辑的资料字段(username 作为登录名走 update_credentials,单独处理)
|
||
_PROFILE_FIELDS = ("nickname", "account", "room", "avatar", "company_avatar", "email", "gender", "birthday")
|
||
|
||
|
||
async def _issue_token(
|
||
db: Database,
|
||
user: dict,
|
||
expires_in: int | None,
|
||
identity: dict | None = None,
|
||
) -> dict:
|
||
"""为指定用户签发 JWT(可按端口身份作用域),返回 token 记录。
|
||
|
||
``identity`` 非空时按该身份解析 role/sub_role/org/region 与权限;
|
||
否则回退到 ``users.role``(单角色/中性令牌)。
|
||
"""
|
||
role = (identity or user).get("role", "opc_member")
|
||
# 账号类型(四种)统一解析;子角色已停用(置 None)
|
||
atype = account_type(role)
|
||
sub_role = None
|
||
org_id = (identity or user).get("org_id")
|
||
region_id = (identity or user).get("region_id")
|
||
identity_id = identity.get("id") if identity else None
|
||
|
||
perms = await db.roles.permissions_for(permission_role(atype), None)
|
||
scope_ids = await db.regions.visible_region_ids(region_id)
|
||
scope_level = await db.regions.level(region_id)
|
||
|
||
token_user = dict(user)
|
||
token_user.update(account_type=atype, role=atype, sub_role=None, org_id=org_id, region_id=region_id)
|
||
await db.users.mark_login(user["id"]) # 记录最近登录时间(每次签发令牌=一次活动)
|
||
return await db.tokens.create(
|
||
token_user,
|
||
permissions=perms,
|
||
scope_region_ids=scope_ids,
|
||
scope_level=scope_level,
|
||
expiry_seconds=config.resolve_token_expiry(expires_in),
|
||
identity_id=identity_id,
|
||
)
|
||
|
||
|
||
async def _profile_for(
|
||
user: dict,
|
||
identity: dict | None,
|
||
db: Database,
|
||
) -> dict:
|
||
"""按身份(或回退用户单角色)构建对外资料字典。"""
|
||
role = (identity or user).get("role", "opc_member")
|
||
atype = account_type(role) # 四种账号类型(子角色已停用)
|
||
sub_role = None
|
||
org_id = (identity or user).get("org_id")
|
||
region_id = (identity or user).get("region_id")
|
||
profile = {f: user.get(f, "") for f in _PROFILE_FIELDS}
|
||
profile.update(username=user.get("username", ""))
|
||
profile["name"] = user.get("nickname") or user.get("username", "")
|
||
profile["phone"] = user.get("phone", "")
|
||
profile["phoneBound"] = bool(user.get("phone", ""))
|
||
profile["wxBound"] = bool(user.get("wx_openid", ""))
|
||
profile["wxMiniBound"] = bool(user.get("wx_mini_openid", ""))
|
||
profile["status"] = user.get("opc_status", "")
|
||
profile["topics"] = _parse_topics(user.get("topics", ""))
|
||
profile["source"] = user.get("source", "")
|
||
profile["auth_type"] = user.get("auth_type", "")
|
||
profile["company"] = user.get("company", "")
|
||
profile["compute_provisioned"] = bool(user.get("compute_provisioned", False))
|
||
profile["compute_quota"] = user.get("compute_quota", 0)
|
||
profile["compute_used_quota"] = user.get("compute_used_quota", 0)
|
||
profile["certification_status"] = user.get("certification_status", "uncertified")
|
||
profile["affiliation"] = user.get("affiliation", "independent")
|
||
profile["park_id"] = user.get("park_id", "")
|
||
profile["park_name"] = user.get("park_name", "")
|
||
profile["account_type"] = atype # 四种账号类型(operator/carrier/opc/"service")
|
||
profile["account_type_label"] = account_type_label(atype)
|
||
profile["role"] = atype # 账号类型(四种)
|
||
profile["sub_role"] = None # 子角色已停用
|
||
profile["org_id"] = org_id
|
||
profile["region_id"] = region_id
|
||
profile["permissions"] = await db.roles.permissions_for(permission_role(atype), None)
|
||
profile["scope_region_ids"] = await db.regions.visible_region_ids(region_id)
|
||
if identity:
|
||
profile["identity_id"] = identity["id"]
|
||
profile["port"] = identity["port"]
|
||
profile["identity_name"] = identity.get("name", "")
|
||
return profile
|
||
|
||
|
||
@router.post("/login", response_model=LoginResponse, summary="登录")
|
||
async def login(req: LoginRequest, db: Database = Depends(get_db)):
|
||
"""校验用户名密码,返回账号绑定的全部端口身份 + 令牌。
|
||
|
||
- 仅一个身份:令牌直接绑定该身份(自动登录)。
|
||
- 多个身份:签发中性账号令牌,前端展示身份选择,经
|
||
``/auth/select-identity`` 切换到指定身份后进入对应端口。
|
||
"""
|
||
# 账号密码登录已全端口停用:统一手机号验证码 / 小程序扫码登录(身份叠加制,角色仅是账号上的业务绑定)
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="账号密码登录已停用,请使用手机号验证码或小程序扫码登录",
|
||
)
|
||
user = await db.users.get_by_username(req.username)
|
||
if user is None or not await db.users.verify_password(user, req.password):
|
||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||
if user.get("status") != "active":
|
||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||
|
||
# 账号单一角色(去多身份):不再按 user_identities 选身份,按 users.role 解析账号类型。
|
||
identity = None
|
||
token_record = await _issue_token(db, user, req.expires_in, identity=None)
|
||
profile = await _profile_for(user, None, db)
|
||
|
||
await db.audit.add(
|
||
action="login", resource="auth", resource_id=user["id"],
|
||
detail=f"login {user['username']} (account_type={profile.get('account_type', '')})",
|
||
user_id=user["id"],
|
||
)
|
||
return LoginResponse(
|
||
token=token_record["token"],
|
||
identities=[],
|
||
**profile,
|
||
)
|
||
|
||
|
||
@router.post("/register", response_model=LoginResponse, summary="注册")
|
||
async def register(req: RegisterRequest, request: Request, db: Database = Depends(get_db)):
|
||
"""注册唯一账户(演示端已存在 pine,故返回 403)。"""
|
||
if not _is_phone(req.username):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="账号需为 11 位手机号(如 138xxxx1234)",
|
||
)
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="Authentication is not enabled")
|
||
if await db.users.has_users():
|
||
raise HTTPException(status_code=403, detail="User already registered")
|
||
if not req.username.strip() or not req.password.strip():
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Username and password are required",
|
||
)
|
||
|
||
user = await db.users.create(
|
||
req.username, req.password,
|
||
source=_client_source(request, getattr(req, "source", ""), "password"),
|
||
auth_type="password", register_ip=_client_ip(request),
|
||
)
|
||
token_record = await _issue_token(db, user, req.expires_in, identity=None)
|
||
profile = await _profile_for(user, None, db)
|
||
return LoginResponse(
|
||
token=token_record["token"],
|
||
identities=[],
|
||
**profile,
|
||
)
|
||
|
||
|
||
@router.get("/status", response_model=AuthStatusResponse, summary="认证状态")
|
||
async def auth_status(db: Database = Depends(get_db)):
|
||
"""前端登录页据此展示登录方式。四种:账号密码 / 短信验证码 / 微信扫码 / 小程序扫码。"""
|
||
modes = ["password", "phone"]
|
||
if config.AUTH_WECHAT_QR:
|
||
modes.append("wechat") # 微信开放平台扫码(OAuth)
|
||
if config.AUTH_WECHAT_LOGIN:
|
||
modes.append("miniprogram") # 小程序扫码(wx.login → code2session)
|
||
return AuthStatusResponse(
|
||
enabled=config.AUTH_ENABLED,
|
||
has_users=await db.users.has_users(),
|
||
login_modes=modes,
|
||
wechat_login=config.AUTH_WECHAT_LOGIN,
|
||
wx_qr=config.AUTH_WECHAT_QR,
|
||
env=config.APP_ENV,
|
||
wxacode_env=config.WECHAT_WXACODE_ENV,
|
||
)
|
||
|
||
|
||
@router.get("/verify", response_model=VerifyResponse, summary="校验令牌")
|
||
async def verify(request: Request, db: Database = Depends(get_db)):
|
||
"""校验调用方 Bearer 令牌;无效令牌由依赖层直接返回 401。"""
|
||
if not config.AUTH_ENABLED:
|
||
return VerifyResponse(valid=True, username="")
|
||
user = await get_current_user(request, db)
|
||
return VerifyResponse(valid=True, username=user["username"])
|
||
|
||
|
||
@router.get("/me", response_model=dict, summary="当前用户资料")
|
||
async def me(
|
||
user: dict = Depends(get_current_user),
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""返回当前登录用户的完整资料(含角色/组织/区域/权限/数据范围)。"""
|
||
return await db.users.to_profile(user)
|
||
|
||
|
||
@router.post("/update-profile", response_model=ProfileResponse, summary="更新资料/凭据")
|
||
async def update_profile(
|
||
req: UpdateProfileRequest,
|
||
user: dict = Depends(get_current_user),
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""更新演示资料与/或用户名、密码,返回更新后的完整资料。
|
||
|
||
- 修改用户名/密码时必须提供正确的 ``current_password``;
|
||
- 修改凭据后吊销该用户其余会话并签发新令牌随响应返回;
|
||
- 仅改资料字段时不重签令牌。
|
||
"""
|
||
payload = req.model_dump(exclude_none=True)
|
||
user_id = user["id"]
|
||
|
||
new_username = payload.get("new_username")
|
||
new_password = payload.get("new_password")
|
||
|
||
if new_username is not None and not new_username.strip():
|
||
raise HTTPException(status_code=400, detail="Username cannot be empty")
|
||
if new_password is not None and not new_password.strip():
|
||
raise HTTPException(status_code=400, detail="Password cannot be empty")
|
||
|
||
from ...infrastructure.oss import to_object_path
|
||
profile_updates = {k: payload[k] for k in _PROFILE_FIELDS if k in payload}
|
||
# 头像等资源字段入库归一化:完整 URL → 对象路径(/oss/<key>),出口再生成 URL
|
||
for _k in ("avatar", "company_avatar"):
|
||
if _k in profile_updates:
|
||
profile_updates[_k] = to_object_path(profile_updates[_k])
|
||
# 小程序契约字段 → users 列映射(name→nickname,status→opc_status,topics→JSON文本)
|
||
if "name" in payload:
|
||
profile_updates["nickname"] = payload["name"]
|
||
if "status" in payload:
|
||
profile_updates["opc_status"] = payload["status"]
|
||
if "topics" in payload:
|
||
t = payload["topics"]
|
||
profile_updates["topics"] = json.dumps(t, ensure_ascii=False) if isinstance(t, list) else (t or "")
|
||
changing_credentials = new_username is not None or new_password is not None
|
||
if not profile_updates and not changing_credentials:
|
||
raise HTTPException(status_code=400, detail="Nothing to update")
|
||
|
||
if changing_credentials and not await db.users.verify_password(user, req.current_password):
|
||
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
||
|
||
if profile_updates:
|
||
await db.users.update_profile(user_id, profile_updates)
|
||
|
||
issued_token = ""
|
||
if changing_credentials:
|
||
await db.users.update_credentials(user_id, new_username, new_password)
|
||
await db.tokens.revoke_all(user_id)
|
||
fresh_user = await db.users.get_by_id(user_id)
|
||
token_record = await _issue_token(db, fresh_user, req.expires_in)
|
||
issued_token = token_record["token"]
|
||
|
||
fresh_user = await db.users.get_by_id(user_id)
|
||
fresh_user["permissions"] = await db.roles.permissions_for(
|
||
fresh_user.get("role", "opc_member"), fresh_user.get("sub_role"),
|
||
)
|
||
fresh_user["scope_region_ids"] = await db.regions.visible_region_ids(fresh_user.get("region_id"))
|
||
return ProfileResponse(
|
||
token=issued_token,
|
||
**await db.users.to_profile(fresh_user),
|
||
)
|
||
|
||
|
||
@router.post("/revoke-token", summary="吊销单个令牌")
|
||
async def revoke_single_token(
|
||
req: RevokeTokenRequest,
|
||
request: Request,
|
||
user: dict = Depends(get_current_user),
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""吊销指定令牌(省略则吊销当前令牌)。"""
|
||
caller_token = extract_bearer_token(request)
|
||
token_to_revoke = req.token or caller_token
|
||
is_current = token_to_revoke == caller_token
|
||
|
||
if not await db.tokens.revoke(token_to_revoke):
|
||
raise HTTPException(status_code=500, detail="Failed to revoke token")
|
||
|
||
message = (
|
||
"Current token has been revoked. Please login again."
|
||
if is_current
|
||
else "Specified token has been revoked."
|
||
)
|
||
return {
|
||
"message": message,
|
||
"revoked": True,
|
||
"revoked_current_token": is_current,
|
||
}
|
||
|
||
|
||
@router.post("/revoke-all-tokens", summary="吊销全部令牌")
|
||
async def revoke_all_sessions(
|
||
user: dict = Depends(get_current_user),
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""吊销所有令牌,所有会话需重新登录。"""
|
||
await db.tokens.revoke_all()
|
||
return {
|
||
"message": "All tokens have been revoked. Please login again.",
|
||
"revoked": True,
|
||
}
|
||
|
||
|
||
# ===========================================================================
|
||
# 统一登录(手机验证码 / 微信)—— 六端登录归一
|
||
# ===========================================================================
|
||
|
||
|
||
async def _ensure_opc_identity(db: Database, user_id: str) -> None:
|
||
"""占位(去多身份/单角色):opc_member 身份已收敛为账号唯一角色,无需再建身份。"""
|
||
return None
|
||
|
||
|
||
async def _login_response_for_user(db: Database, user: dict) -> LoginResponse:
|
||
"""统一登录响应:令牌角色 = 账号单一角色(users.role → account_type)。
|
||
|
||
同一账号无论 手机号/微信/小程序 哪个方式登录,角色与权限均一致(= 账号密码登录结果)。
|
||
"""
|
||
token_record = await _issue_token(db, user, None, identity=None)
|
||
profile = await _profile_for(user, None, db)
|
||
return LoginResponse(
|
||
token=token_record["token"],
|
||
identities=[],
|
||
**profile,
|
||
)
|
||
|
||
|
||
@router.post("/send-code", response_model=SendCodeResponse, summary="发送短信验证码")
|
||
async def send_code(req: SendCodeRequest, db: Database = Depends(get_db)):
|
||
"""为手机号发送登录验证码。stub provider 仅在服务日志打印。"""
|
||
if not _is_phone(req.phone):
|
||
raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)")
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
code = sms.issue(req.phone)
|
||
return SendCodeResponse(
|
||
sent=True,
|
||
stub_code=code if config.SMS_PROVIDER == "stub" else None,
|
||
)
|
||
|
||
|
||
@router.post("/phone-login", response_model=LoginResponse, summary="手机号验证码登录")
|
||
async def phone_login(req: PhoneLoginRequest, request: Request, db: Database = Depends(get_db)):
|
||
"""手机号 + 验证码登录:无账号则注册(建 opc_member 身份)。"""
|
||
if not _is_phone(req.phone):
|
||
raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)")
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
try:
|
||
sms.verify(req.phone, req.code)
|
||
except sms.SmsError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
|
||
# 手机号唯一:只按「已绑定手机 phone 列」查(登录名 username 与手机号是独立概念,不互查)。
|
||
user = await db.users.find_by_phone(req.phone)
|
||
if user is None:
|
||
# 登录名独立生成(不=手机号),手机号存入 phone 列;用户以短信码登录。
|
||
user = await db.users.create(
|
||
f"u_{secrets.token_hex(10)}", password=secrets.token_hex(16),
|
||
phone=req.phone, role="opc_member",
|
||
source=_client_source(request, getattr(req, "source", ""), "phone"),
|
||
auth_type="phone", register_ip=_client_ip(request),
|
||
)
|
||
if user.get("status") != "active":
|
||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||
|
||
await _ensure_opc_identity(db, user["id"])
|
||
await db.audit.add(
|
||
action="login", resource="auth", resource_id=user["id"],
|
||
detail=f"phone-login {req.phone}", user_id=user["id"],
|
||
)
|
||
return await _login_response_for_user(db, user)
|
||
|
||
|
||
@router.post("/wx-login", response_model=LoginResponse, summary="微信扫码登录(预留)")
|
||
async def wx_login(req: WxLoginRequest, request: Request, db: Database = Depends(get_db)):
|
||
"""微信扫码登录:本期预留未开放。开启需 PINEAGENTS_WECHAT_LOGIN=true 且配置 appid/secret。"""
|
||
if not config.AUTH_WECHAT_LOGIN:
|
||
raise HTTPException(status_code=503, detail="微信登录预留中,本期未开放")
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
try:
|
||
openid = await wechat.code2session(req.code)
|
||
except wechat.WechatError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
|
||
source = _client_source(request, getattr(req, "source", ""), "wx_openid")
|
||
is_mini = source == "mini_program"
|
||
# 小程序登录存 wx_mini_openid(与网页/公众号 openid 不同);否则存 wx_openid
|
||
if is_mini:
|
||
user = await db.users.get_by_wx_mini_openid(openid) or await db.users.get_by_wx_openid(openid)
|
||
else:
|
||
user = await db.users.get_by_wx_openid(openid)
|
||
if user is None:
|
||
user = await db.users.create(
|
||
f"wx_{secrets.token_hex(10)}", password=secrets.token_hex(16),
|
||
wx_openid=openid if not is_mini else "",
|
||
wx_mini_openid=openid if is_mini else "",
|
||
nickname=str(getattr(req, "nickName", "") or ""),
|
||
avatar=str(getattr(req, "avatarUrl", "") or ""),
|
||
role="opc_member", source=source, auth_type="wx_openid",
|
||
register_ip=_client_ip(request),
|
||
)
|
||
if user.get("status") != "active":
|
||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||
|
||
await _ensure_opc_identity(db, user["id"])
|
||
await db.audit.add(
|
||
action="login", resource="auth", resource_id=user["id"],
|
||
detail=f"wx-login {openid[:16]}", user_id=user["id"],
|
||
)
|
||
return await _login_response_for_user(db, user)
|
||
|
||
|
||
@router.post("/wx-phone", summary="微信补绑手机号")
|
||
async def wx_phone(req: WxPhoneRequest, db: Database = Depends(get_db)):
|
||
"""给已登录的微信账号补绑手机号。若该手机号被其它账号占用,则按手机号合并(不新增账号)。"""
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
try:
|
||
sms.verify(req.phone, req.code)
|
||
except sms.SmsError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
|
||
user = await db.users.get_by_wx_openid(req.openid)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="微信账号不存在,请先 wx-login")
|
||
user = await _bind_phone_merge(db, user, req.phone)
|
||
await db.audit.add(
|
||
action="bind-phone", resource="auth", resource_id=user["id"],
|
||
detail=f"wx bind phone {req.phone}", user_id=user["id"],
|
||
)
|
||
return await _login_response_for_user(db, user)
|
||
|
||
|
||
# ===========================================================================
|
||
# 统一账号:绑定/解绑登录方式 + 按手机号合并(同一账号,绝不新增)
|
||
# ===========================================================================
|
||
|
||
|
||
def _login_method_count(user: dict) -> int:
|
||
"""可用登录方式数:手机号 / 微信(网页) / 小程序。用于解绑守卫(至少留一种)。"""
|
||
return sum(1 for k in ("phone", "wx_openid", "wx_mini_openid") if (user or {}).get(k))
|
||
|
||
|
||
async def _bind_phone_merge(db: Database, user: dict, phone: str) -> dict:
|
||
"""给用户绑定手机号;若该手机号已被其它账号占用,则按「手机号持有者=keeper」合并。
|
||
|
||
返回登录账号(可能是合并后的 keeper)。keeper 已持有该手机号,absorbed=当前账号并入。
|
||
"""
|
||
owner = await db.users.find_by_phone(phone)
|
||
if owner and owner["id"] != user["id"]:
|
||
# keeper=已持有该手机号的账号;当前账号被并入 → 微信等身份挂在 keeper 下
|
||
keeper = await db.users.merge_accounts(owner["id"], user["id"])
|
||
return keeper or owner
|
||
fresh = await db.users.set_phone(user["id"], phone)
|
||
return fresh or user
|
||
|
||
|
||
@router.post("/bind-phone", response_model=LoginResponse, summary="当前账号绑定手机号")
|
||
async def bind_phone(
|
||
req: BindPhoneRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
"""绑定手机号(验证码)。若该手机号已被其它账号占用 → 按手机号合并为同一账号。
|
||
|
||
合并后当前会话切换为 keeper(返回其新令牌),前端应更新本地令牌。
|
||
"""
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
if not _is_phone(req.phone):
|
||
raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)")
|
||
try:
|
||
sms.verify(req.phone, req.code)
|
||
except sms.SmsError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
merged = await _bind_phone_merge(db, user, req.phone)
|
||
await _ensure_opc_identity(db, merged["id"])
|
||
await db.audit.add(
|
||
action="bind-phone", resource="auth", resource_id=merged["id"],
|
||
detail=f"bind phone {req.phone} (merged)" if merged["id"] != user["id"] else f"bind phone {req.phone}",
|
||
user_id=merged["id"],
|
||
)
|
||
return await _login_response_for_user(db, merged)
|
||
|
||
|
||
@router.post("/unbind", summary="解绑登录方式(保留至少一种)")
|
||
async def unbind(req: UnbindRequest, db: Database = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
"""解除已绑定的登录方式:phone / wx / wx_mini。不能解绑到没有任何可用登录方式。"""
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
if req.type not in ("phone", "wx", "wx_mini"):
|
||
raise HTTPException(status_code=400, detail="type 需为 phone/wx/wx_mini")
|
||
# 解绑后仍保留至少一种登录方式
|
||
remaining = _login_method_count(user) - (1 if user.get({ # noqa: E501
|
||
"phone": "phone", "wx": "wx_openid", "wx_mini": "wx_mini_openid",
|
||
}[req.type]) else 0)
|
||
if remaining <= 0:
|
||
raise HTTPException(status_code=409, detail="至少要保留一种登录方式(手机号/微信/小程序)")
|
||
fresh = await db.users.clear_binding(user["id"], req.type)
|
||
await db.audit.add(
|
||
action="unbind", resource="auth", resource_id=user["id"],
|
||
detail=f"unbind {req.type}", user_id=user["id"],
|
||
)
|
||
return {**await db.users.to_profile(fresh), "unbound": req.type}
|
||
|
||
|
||
# ===========================================================================
|
||
# 微信开放平台扫码登录(标准 OAuth:qr_start → 扫码 → wx-callback → poll)
|
||
# ===========================================================================
|
||
|
||
|
||
async def _find_or_create_wx_user(
|
||
openid: str, request: Request, db: Database,
|
||
) -> dict:
|
||
"""按扫码登录的 openid(存 wx_openid)查找/创建用户(幂等,唯一)。"""
|
||
user = await db.users.get_by_wx_openid(openid)
|
||
if user is None:
|
||
user = await db.users.create(
|
||
f"wx_{secrets.token_hex(10)}", password=secrets.token_hex(16),
|
||
wx_openid=openid, nickname="微信用户",
|
||
role="opc_member",
|
||
source=_client_source(request, "", "wx_openid"), auth_type="wx_openid",
|
||
register_ip=_client_ip(request),
|
||
)
|
||
if user.get("status") != "active":
|
||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||
return user
|
||
|
||
|
||
@router.get("/wx-qr/start", summary="发起微信扫码登录")
|
||
async def wx_qr_start(request: Request, db: Database = Depends(get_db)):
|
||
"""生成扫码登录会话:返回 scene + 授权链接(渲染二维码)。
|
||
|
||
- 前端把 ``qr_url`` 渲染成二维码展示;扫码后在微信内授权。
|
||
- 授权回跳 ``/auth/wx-callback``,前端轮询 ``/auth/wx-qr/poll?scene=`` 取令牌。
|
||
"""
|
||
if not config.AUTH_WECHAT_QR:
|
||
raise HTTPException(status_code=503, detail="微信扫码登录未开启")
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
scene = wechat.new_scene()
|
||
redirect_uri = _wx_redirect_uri(request)
|
||
qr_url = wechat.qr_connect_url(scene, redirect_uri)
|
||
wx_qr_store.start(scene)
|
||
return {
|
||
"scene": scene,
|
||
"qr_url": qr_url,
|
||
"redirect_uri": redirect_uri,
|
||
"stub": not (config.WECHAT_OPEN_APPID and config.WECHAT_OPEN_SECRET),
|
||
}
|
||
|
||
|
||
@router.get("/wx-callback", summary="微信扫码授权回跳")
|
||
async def wx_callback_get(
|
||
request: Request,
|
||
code: str = "",
|
||
state: str = "",
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""微信扫码授权回跳:换 openid → 建/复用账号 → 签发令牌,存到 scene 会话。"""
|
||
if not code:
|
||
raise HTTPException(status_code=400, detail="缺少 code")
|
||
try:
|
||
openid = await wechat.oauth_code2openid(code)
|
||
except wechat.WechatError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
user = await _find_or_create_wx_user(openid, request, db)
|
||
await _ensure_opc_identity(db, user["id"])
|
||
resp = await _login_response_for_user(db, user)
|
||
wx_qr_store.complete(state or openid, resp.token, resp.model_dump())
|
||
return _wx_success_html()
|
||
|
||
|
||
@router.post("/wx-callback", summary="微信扫码授权回跳(SPA 直交)")
|
||
async def wx_callback_post(
|
||
request: Request,
|
||
code: str = "",
|
||
state: str = "",
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""SPA 自行拿到 code 后 POST 到此:直接返回登录响应(不走轮询)。"""
|
||
if not code:
|
||
raise HTTPException(status_code=400, detail="缺少 code")
|
||
try:
|
||
openid = await wechat.oauth_code2openid(code)
|
||
except wechat.WechatError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
user = await _find_or_create_wx_user(openid, request, db)
|
||
return await _login_response_for_user(db, user)
|
||
|
||
|
||
@router.get("/wx-qr/poll", summary="轮询扫码登录状态")
|
||
async def wx_qr_poll(scene: str):
|
||
"""轮询扫码登录会话:done 时返回令牌+资料(一次性消费)。"""
|
||
result = wx_qr_store.poll(scene)
|
||
if result["status"] == "done":
|
||
return {"status": "done", "token": result["token"], "profile": result["profile"]}
|
||
return {"status": result["status"]}
|
||
|
||
|
||
# ===========================================================================
|
||
# 小程序扫码登录(web/桌面端展示小程序二维码 → 小程序内确认 → 轮询取令牌)
|
||
# ===========================================================================
|
||
|
||
|
||
@router.get("/mp-qr/start", summary="发起小程序扫码登录")
|
||
async def mp_qr_start(request: Request, db: Database = Depends(get_db)):
|
||
"""生成小程序扫码登录会话,返回 scene + 小程序码图片。
|
||
|
||
用于「小程序扫码登录其它端口」:桌面/admin/网页登录页展示该码,
|
||
用户用 **微信扫一扫** → 自动打开小程序「扫码登录」页(复用其登录身份)确认,
|
||
目标端口轮询取得登录令牌。scene 用短 token(wxacode 限 32 字符)。
|
||
|
||
未配置 WECHAT_APPID/SECRET 时退化为普通二维码(qr_url),仅支持「小程序内
|
||
scanCode 路径」,无法被微信自动拉起小程序。
|
||
"""
|
||
if not config.AUTH_WECHAT_LOGIN:
|
||
raise HTTPException(status_code=503, detail="小程序登录未开启")
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
scene = f"mp_{secrets.token_hex(10)}" # 3+20 = 23 字符
|
||
wx_qr_store.start(scene)
|
||
# 生成小程序码(微信扫码自动打开小程序 pages/scan-login 并携带 scene)
|
||
try:
|
||
png = await wechat.get_wxacode(scene, page="pages/scan-login/index")
|
||
b64 = base64.b64encode(png).decode("ascii")
|
||
return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True, "expires_in": wx_qr_store.TTL_SECONDS}
|
||
except wechat.WechatError:
|
||
# 无凭据/失败兜底:返回可扫描的 URL,前端按 qr_url 渲染普通二维码
|
||
qr_url = f"https://opc.pinesound.cn/mp/login?scene={scene}"
|
||
return {"scene": scene, "qr_url": qr_url, "mp_enabled": False, "expires_in": wx_qr_store.TTL_SECONDS}
|
||
|
||
|
||
@router.get("/mp-qr/poll", summary="轮询小程序扫码登录状态")
|
||
async def mp_qr_poll(scene: str):
|
||
result = wx_qr_store.poll(scene)
|
||
if result["status"] == "done":
|
||
return {"status": "done", "token": result["token"], "profile": result["profile"], "message": result["message"]}
|
||
return {"status": result["status"], "message": ""}
|
||
|
||
|
||
@router.get("/mp-qr/bind-start", summary="发起「绑定小程序」")
|
||
async def mp_qr_bind_start(request: Request, db: Database = Depends(get_db), user: dict = Depends(get_current_user)):
|
||
"""把当前账号与小程序(微信身份)绑定:返回小程序码;微信扫码自动打开小程序确认页。
|
||
|
||
与登录不同,这是「绑定」——确认后把该微信身份挂到**当前账号**(若该微信已属另一账号则合并)。
|
||
"""
|
||
if not config.AUTH_WECHAT_LOGIN:
|
||
raise HTTPException(status_code=503, detail="小程序登录未开启")
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
scene = f"mp_{secrets.token_hex(10)}"
|
||
wx_qr_store.start(scene, bind_user_id=user["id"])
|
||
try:
|
||
png = await wechat.get_wxacode(scene, page="pages/scan-login/index")
|
||
b64 = base64.b64encode(png).decode("ascii")
|
||
return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True, "expires_in": wx_qr_store.TTL_SECONDS}
|
||
except wechat.WechatError:
|
||
qr_url = f"https://opc.pinesound.cn/mp/bind?scene={scene}"
|
||
return {"scene": scene, "qr_url": qr_url, "mp_enabled": False, "expires_in": wx_qr_store.TTL_SECONDS}
|
||
|
||
|
||
@router.post("/mp-qr/confirm", summary="小程序内确认扫码登录/绑定")
|
||
async def mp_qr_confirm(request: Request, req: MpQrConfirmRequest, db: Database = Depends(get_db)):
|
||
"""小程序内 wx.login 后调用:以 scene 关联 Web 端会话。
|
||
|
||
- 登录态(scene 无 bind_user_id):复用登录换取令牌回写 scene;
|
||
- 绑定态(scene 有 bind_user_id):把该微信身份绑到 bind_user_id 账号(若该微信属另一账号则合并)。
|
||
"""
|
||
if not config.AUTH_ENABLED:
|
||
raise HTTPException(status_code=403, detail="认证未开启")
|
||
try:
|
||
openid = await wechat.code2session(req.code)
|
||
except wechat.WechatError as exc:
|
||
raise HTTPException(status_code=401, detail=str(exc))
|
||
source = _client_source(request, "mini_program", "wx_openid")
|
||
|
||
# 绑定态:把 openid 绑到 scene 记录的目标账号
|
||
bind_user_id = wx_qr_store.bind_user(req.scene)
|
||
if bind_user_id:
|
||
target = await db.users.get_by_id(bind_user_id)
|
||
if target is None:
|
||
raise HTTPException(status_code=404, detail="目标账号不存在,请重新发起绑定")
|
||
owner = await db.users.get_by_wx_mini_openid(openid)
|
||
if owner and owner["id"] != target["id"]:
|
||
# 该微信身份已属另一账号 → 合并到当前账号(同一账号不新建)
|
||
merged = await db.users.merge_accounts(target["id"], owner["id"])
|
||
if not merged or not merged.get("wx_mini_openid"):
|
||
merged = await db.users.set_wx_mini_openid(target["id"], openid)
|
||
target = merged
|
||
elif not (target.get("wx_mini_openid") or ""):
|
||
target = await db.users.set_wx_mini_openid(target["id"], openid)
|
||
wx_qr_store.complete(req.scene, message="bound")
|
||
return {"ok": True, "bound": True, "username": (target or {}).get("username", "")}
|
||
|
||
# 登录态:登录换令牌回写 scene
|
||
user = await db.users.get_by_wx_mini_openid(openid)
|
||
if user is None:
|
||
user = await db.users.create(
|
||
f"wx_{secrets.token_hex(10)}", password=secrets.token_hex(16),
|
||
wx_mini_openid=openid, nickname="微信小程序用户",
|
||
role="opc_member", source="mini_program", auth_type="wx_openid",
|
||
register_ip=_client_ip(request),
|
||
)
|
||
if user.get("status") != "active":
|
||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||
await _ensure_opc_identity(db, user["id"])
|
||
resp = await _login_response_for_user(db, user)
|
||
wx_qr_store.complete(req.scene, resp.token, resp.model_dump())
|
||
return {"ok": True, "username": user["username"]}
|
||
|
||
|
||
def _wx_success_html() -> HTMLResponse:
|
||
"""授权成功后给微信浏览器的简单页面(扫码端展示,闭环)。"""
|
||
return HTMLResponse(
|
||
"<!doctype html><html lang=zh><meta charset=utf-8>"
|
||
"<meta name=viewport content='width=device-width'>"
|
||
"<body style='display:flex;align-items:center;justify-content:center;"
|
||
"height:100vh;font-family:sans-serif'><div style='text-align:center'>"
|
||
"<div style='font-size:48px'>✅</div>"
|
||
"<h3>微信登录成功</h3>"
|
||
"<p>请返回原应用继续操作(本页可关闭)</p></div></body></html>"
|
||
)
|