Files

419 lines
15 KiB
Python
Raw Permalink 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.
"""公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线)"""
import base64
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import verify_password
from app.core.auth import (
create_access_token,
create_refresh_token,
decode_token,
get_current_user_by_token,
extend_session,
is_refresh_token_valid,
is_session_valid,
revoke_refresh_token,
revoke_session,
store_refresh_token,
)
from app.core.response import success_response, error_response
from app.core.config import settings
from app.crud.user import user_crud, change_password
from app.core.utils import generate_uuid
from app.core import login_crypto
from app.services.web_auth_roles import (
iter_user_menu_perm_codes,
resolve_web_role_for_user,
user_has_bound_roles,
)
from app.services.web_login_tokens import (
_jwt_user_payload,
_web_role_list,
build_user_info_payload,
issue_web_login_tokens,
)
from app.services.mp_webview_ticket import TICKET_TTL_SEC, consume_ticket, issue_ticket
from app.services import wx_qr_session
from app.services.wechat_mini_auth import WechatMiniAuthError, get_unlimited_wxacode
from app.crud.crm.quote import view_by_share_code
router = APIRouter(prefix="", tags=["公共接口"])
@router.get("/web/auth/login-rsa-public-key", response_model=dict)
def web_login_rsa_public_key():
"""Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)"""
login_crypto.ensure_initialized()
return success_response({"publicKeyPem": login_crypto.get_public_pem()})
@router.post("/web/auth/login", response_model=dict)
def web_login(
request: Request,
body: dict,
db: Session = Depends(get_db),
):
"""Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。"""
login_crypto.ensure_initialized()
user_no, password, cred_err = login_crypto.extract_login_credentials(body)
if cred_err:
return error_response(400, cred_err)
user = user_crud.get_by_user_no_for_web_login(db, user_no)
if not user:
return error_response(400, "用户名或密码错误")
pwd_hash = getattr(user, "password", None) or ""
if not verify_password(password, pwd_hash):
return error_response(400, "用户名或密码错误")
if user.status != "1":
return error_response(403, "账号已被停用,请联系管理员")
try:
data = issue_web_login_tokens(db, user)
except ValueError as exc:
return error_response(403, str(exc))
return success_response(data)
@router.post("/web/auth/wx-qr/create", response_model=dict)
async def wx_qr_create():
session_id, short = wx_qr_session.create_session()
page = (settings.WX_QR_PAGE or "pages/login/web-qr/index").strip()
try:
raw = get_unlimited_wxacode(
scene=short,
page=page,
env_version=settings.WX_QR_ENV_VERSION,
)
except WechatMiniAuthError as exc:
wx_qr_session.invalidate(session_id)
return error_response(400, str(exc))
b64 = base64.b64encode(raw).decode("ascii")
mime = "image/jpeg" if raw[:2] == b"\xff\xd8" else "image/png"
return success_response(
{
"sessionId": session_id,
"expiresIn": settings.WX_QR_SESSION_TTL_SEC,
"qrImageBase64": f"data:{mime};base64,{b64}",
}
)
@router.get("/web/auth/wx-qr/status", response_model=dict)
async def wx_qr_status(sessionId: str = ""):
sid = (sessionId or "").strip()
data = wx_qr_session.get_session(sid)
if not data:
return success_response({"status": "expired", "message": "二维码已过期,请刷新"})
status = data.get("status") or "expired"
if status == "success":
tokens = wx_qr_session.consume_success(sid)
if not tokens:
return success_response({"status": "expired", "message": "登录凭证已失效,请刷新二维码"})
return success_response({"status": "success", **tokens})
if status == "failed":
return success_response({"status": "failed", "message": data.get("message") or "登录失败"})
if status in ("expired", "consumed"):
return success_response({"status": "expired", "message": "二维码已过期,请刷新"})
return success_response({"status": status})
@router.post("/web/auth/refresh-token", response_model=dict)
def refresh_token(body: dict, db: Session = Depends(get_db)):
"""刷新 Token"""
refresh_token_val = body.get("refreshToken")
if not refresh_token_val:
return error_response(400, "refreshToken不能为空")
try:
payload = decode_token(refresh_token_val)
if payload.get("type") != "refresh":
return error_response(401, "无效的refreshToken")
session_id = payload.get("sid")
refresh_jti = payload.get("jti")
if not is_session_valid(session_id=session_id):
return error_response(401, "refreshToken已失效,请重新登录")
if not is_refresh_token_valid(refresh_jti=refresh_jti):
return error_response(401, "无效的refreshToken")
user_id = payload.get("sub")
user = user_crud.get(db, user_id)
if not user or user.del_flag != 0 or user.status != "1":
return error_response(401, "用户不存在或已禁用")
web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
revoke_refresh_token(refresh_jti=refresh_jti)
extend_session(session_id=session_id)
new_access_jti = generate_uuid()
new_refresh_jti = generate_uuid()
new_access_token = create_access_token(
data=_jwt_user_payload(user, web_role),
session_id=session_id,
token_jti=new_access_jti,
)
new_refresh_token = create_refresh_token(
data={
"sub": user.id,
"username": user.user_no,
"role": web_role,
},
session_id=session_id,
refresh_jti=new_refresh_jti,
)
store_refresh_token(refresh_jti=new_refresh_jti, user_id=user.id)
return success_response(
{
"token": new_access_token,
"refreshToken": new_refresh_token,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
}
)
except HTTPException:
return error_response(401, "refreshToken已过期")
@router.post("/web/auth/mp-webview-ticket", response_model=dict)
def mp_webview_ticket(request: Request, db: Session = Depends(get_db)):
"""小程序内嵌 WebView:签发一次性短时 ticket(需 Bearer)。"""
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
return error_response(401, "未授权")
token = authorization.replace("Bearer ", "").strip()
try:
current_user = get_current_user_by_token(token=token, db=db)
payload = decode_token(token)
except HTTPException:
return error_response(401, "Token无效或已过期")
session_id = str(payload.get("sid") or "").strip()
if not session_id:
return error_response(401, "Token无效或已过期")
try:
ticket = issue_ticket(
user_id=str(current_user.get("user_id") or ""),
session_id=session_id,
refresh_jti=str(payload.get("jti") or ""),
)
except RuntimeError:
return error_response(503, "暂时无法打开内嵌页,请稍后重试")
return success_response({"ticket": ticket, "expiresIn": TICKET_TTL_SEC})
@router.post("/web/auth/mp-webview-exchange", response_model=dict)
def mp_webview_exchange(body: dict, db: Session = Depends(get_db)):
"""小程序内嵌 WebView:用 ticket 兑换 Web JWT(不吊销原 refresh)。"""
ticket = str((body or {}).get("ticket") or "").strip()
if not ticket:
return error_response(400, "ticket不能为空")
try:
data = consume_ticket(ticket)
except RuntimeError:
return error_response(503, "暂时无法打开内嵌页,请稍后重试")
if not data:
return error_response(401, "ticket无效或已过期")
user_id = str(data.get("userId") or "").strip()
session_id = str(data.get("sessionId") or "").strip()
if not user_id or not session_id:
return error_response(401, "ticket无效或已过期")
if not is_session_valid(session_id):
return error_response(401, "登录已失效,请重新登录")
user = user_crud.get(db, user_id)
if not user or getattr(user, "del_flag", 0) != 0:
return error_response(401, "用户不存在或已删除")
if user.status != "1":
return error_response(403, "账号已被停用,请联系管理员")
web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
access_jti = generate_uuid()
refresh_jti = generate_uuid()
access_token = create_access_token(
data=_jwt_user_payload(user, web_role),
session_id=session_id,
token_jti=access_jti,
)
refresh_token_val = create_refresh_token(
data={"sub": user.id, "username": user.user_no, "role": web_role},
session_id=session_id,
refresh_jti=refresh_jti,
)
store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
return success_response(
{
"token": access_token,
"refreshToken": refresh_token_val,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
"userInfo": build_user_info_payload(user, web_role),
}
)
@router.post("/web/auth/logout", response_model=dict)
def logout(request: Request):
"""登出:吊销 Redis 会话(若已配置 REDIS_HOST)。无 Token 或已失效时仍返回成功(幂等)。"""
authorization = request.headers.get("Authorization", "")
if authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "")
try:
payload = decode_token(token)
session_id = payload.get("sid")
if session_id:
revoke_session(session_id=session_id)
except HTTPException:
pass
return success_response({"message": "登出成功"})
@router.get("/web/auth/current-user", response_model=dict)
def get_current_user_profile(request: Request, db: Session = Depends(get_db)):
"""获取当前用户信息(与 sys_user 字段对应)。"""
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
return error_response(401, "未授权")
token = authorization.replace("Bearer ", "")
try:
current_user = get_current_user_by_token(token=token, db=db)
au = current_user.get("avatar_url")
avatar_url = str(au).strip() if au else None
web_role = str(current_user.get("role") or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
return success_response(
{
"id": current_user.get("user_id"),
"username": current_user.get("username"),
"realName": current_user.get("real_name"),
"phone": current_user.get("phone"),
"email": current_user.get("email"),
"role": web_role,
"roles": _web_role_list(web_role),
"isSuperAdmin": web_role == "super_admin",
"regionId": current_user.get("region_id"),
"regionName": None,
"regionIds": [],
"isActive": current_user.get("is_active"),
"avatarUrl": avatar_url if avatar_url else None,
}
)
except HTTPException:
return error_response(401, "Token无效或已过期")
@router.get("/web/auth/menu-codes", response_model=dict)
def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):
"""获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。"""
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
return error_response(401, "未授权")
token = authorization.replace("Bearer ", "")
try:
current_user = get_current_user_by_token(token=token, db=db)
user_id = current_user.get("user_id") or ""
tenant_no = current_user.get("tenant_no")
if not user_has_bound_roles(db, user_id, tenant_no):
return success_response(
{
"isSuperAdmin": False,
"all": False,
"menuCodes": [],
}
)
role = current_user.get("role")
if role == "super_admin":
return success_response(
{
"isSuperAdmin": True,
"all": True,
"menuCodes": [],
}
)
try:
codes = iter_user_menu_perm_codes(db, user_id, tenant_no)
menu_codes = sorted(codes)
except OperationalError:
menu_codes = []
return success_response(
{
"isSuperAdmin": False,
"all": False,
"menuCodes": menu_codes,
}
)
except HTTPException:
return error_response(401, "Token无效或已过期")
@router.post("/web/auth/change-password", response_model=dict)
def change_current_user_password(
request: Request,
body: dict,
db: Session = Depends(get_db),
):
"""当前用户修改密码。"""
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
return error_response(401, "未授权")
token = authorization.replace("Bearer ", "")
try:
current_user = get_current_user_by_token(token=token, db=db)
user_id = current_user.get("user_id")
if not user_id:
return error_response(401, "未授权")
old_password = (body.get("oldPassword") or body.get("old_password") or "").strip()
new_password = (body.get("newPassword") or body.get("new_password") or "").strip()
if not old_password:
return error_response(400, "请输入原密码")
if len(new_password) < 6:
return error_response(400, "新密码长度不能少于6位")
change_password(db, user_id, old_password, new_password)
return success_response({"message": "密码修改成功"})
except HTTPException:
return error_response(401, "Token无效或已过期")
except ValueError as exc:
return error_response(400, str(exc))
@router.get("/public/crm/quote/share/{share_code}")
def public_quote_share(
share_code: str,
request: Request,
password: str = "",
db: Session = Depends(get_db),
):
"""公开分享查看报价单 — Java: GET /share/{shareCode}(无 JWT)"""
try:
ip_address = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
detail = view_by_share_code(
db,
share_code,
password=password or "",
ip_address=ip_address,
user_agent=user_agent,
)
return success_response(detail)
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, str(e))