"""认证相关 endpoint。 路由前缀 `/api/v1/auth`,包含: POST /jverify-login 极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT) POST /sms/send 发短信验证码(mock 阶段任意 6 位通过) POST /sms/login 手机号 + 验证码登录 POST /refresh 用 refresh_token 换新的 token 对 GET /me 当前登录用户(Bearer access_token) POST /logout 客户端登出(目前服务端无状态,仅返回 ok。后续加 jti 黑名单) """ from __future__ import annotations import logging from fastapi import APIRouter, HTTPException, Request, status from sqlalchemy.exc import IntegrityError from app.api.deps import CurrentUser, DbSession from app.core import test_account from app.core.ratelimit import enforce_rate_limit from app.core.security import ( TokenError, create_bind_ticket, create_conflict_ticket, decode_bind_ticket, decode_conflict_ticket, decode_token, issue_token_pair, ) from app.integrations import wxpay from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code from app.repositories import onboarding as onboarding_repo from app.repositories import phone_rebind as rebind_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, LogoutResponse, OccupiedAccountInfo, RefreshRequest, SmsLoginRequest, SmsSendRequest, SmsSendResponse, TokenPair, TokenWithUser, UserOut, WechatBindPhoneJverifyRequest, WechatBindPhoneSmsRequest, WechatBindResultResponse, WechatConflictContinueRequest, WechatConflictRebindRequest, WechatLoginRequest, WechatLoginResponse, ) logger = logging.getLogger("shagua.auth") router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 # 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 # 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。 SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 def _login_response( user, *, onboarding_completed: bool, force_onboarding: bool = False ) -> TokenWithUser: """登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入; force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。""" tokens = issue_token_pair(user.id) return TokenWithUser( **tokens, user=UserOut.model_validate(user), onboarding_completed=onboarding_completed, force_onboarding=force_onboarding, ) # ===================== 极光一键登录 ===================== @router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录") def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: logger.info( "jverify_login operator=%s token_len=%d", req.operator or "-", len(req.login_token), ) try: phone = verify_and_get_phone(req.login_token) except JiguangError as e: logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True) raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify") if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed) return _login_response(user, onboarding_completed=completed) # ===================== 短信登录 ===================== @router.post( "/sms/send", response_model=SmsSendResponse, summary="发送短信验证码", ) def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: # 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。 # 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。 # (也不受下面设备发码限流约束:QA 联调要反复发码。) if test_account.is_test_account(req.phone): logger.info("test_account sms_send short-circuit (不真发)") return SmsSendResponse(sent=True, mock=True, cooldown_sec=0) # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。 # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶, # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。 enforce_rate_limit( request, scope="sms-send-device", subject=req.device_id, limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE, window_sec=3600, detail="操作过于频繁,请稍后再试", ) try: cooldown = send_code(req.phone) except SmsError as e: raise HTTPException(status_code=e.status_code, detail=str(e)) from e from app.core.config import settings # 局部 import 避免循环 return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown) @router.post( "/sms/login", response_model=TokenWithUser, summary="手机号+验证码登录", ) def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser: # 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。 # 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。 # 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。 if test_account.is_test_account(req.phone): if not test_account.try_consume_quota(): raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试") user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms") if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone)) # onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登), # 不读/不写 onboarding_completion 表。 return _login_response(user, onboarding_completed=False, force_onboarding=True) # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验 # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。 # ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时 # 退化为该 IP 下所有空设备聚一桶,仍受限。 enforce_rate_limit( request, scope="sms-login-device", subject=req.device_id, limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="登录尝试过于频繁,请稍后再试", ) if not verify_code(req.phone, req.code): raise HTTPException(status_code=400, detail="invalid sms code") user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms") if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed) return _login_response(user, onboarding_completed=completed) # ===================== 微信登录 ===================== @router.post( "/wechat-login", response_model=WechatLoginResponse, summary="微信登录(openid 命中即登入,否则发绑号令牌)", ) def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse: from app.core.config import settings # 局部 import,避免循环 # 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。 if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET): raise HTTPException(status_code=503, detail="wechat login not configured") try: info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e openid = info["openid"] user = user_repo.get_user_by_wechat_openid(db, openid) if user is not None: # openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑) if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") user_repo.touch_last_login(db, user) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed) return WechatLoginResponse( status="logged_in", token=_login_response(user, onboarding_completed=completed), ) # 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建) ticket = create_bind_ticket( openid=openid, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"], ) logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6]) return WechatLoginResponse( status="need_bind_phone", bind_ticket=ticket, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"], ) def _finish_wechat_bind( db, *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None, phone: str, device_id: str, ) -> WechatBindResultResponse: """绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1); 未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。""" existing = user_repo.get_user_by_phone(db, phone) if existing is not None: from app.core.config import settings # 局部 import,避免循环 ticket = create_conflict_ticket( openid=openid, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url, phone=phone, ) blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) logger.info( "wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s", mask_phone(phone), existing.id, bool(existing.wechat_openid), ) return WechatBindResultResponse( status="phone_occupied", occupied_account=OccupiedAccountInfo( nickname=existing.nickname, avatar_url=existing.avatar_url, created_at=existing.created_at, has_wechat=bool(existing.wechat_openid), ), conflict_ticket=ticket, rebind_available=not blocked, rebind_blocked_days=( rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) if blocked else 0 ), ) user = user_repo.create_wechat_user( db, phone=phone, openid=openid, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url, ) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id) logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s", user.id, mask_phone(phone), openid[:6], completed) return WechatBindResultResponse( status="logged_in", token=_login_response(user, onboarding_completed=completed), ) @router.post( "/wechat/bind-phone/sms", response_model=WechatBindResultResponse, summary="微信登录·其他手机号(短信)绑定", ) def wechat_bind_phone_sms( req: WechatBindPhoneSmsRequest, request: Request, db: DbSession ) -> WechatBindResultResponse: try: claims = decode_bind_ticket(req.bind_ticket) except TokenError as e: raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e # 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数) enforce_rate_limit( request, scope="wechat-bind-sms-device", subject=req.device_id, limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="登录尝试过于频繁,请稍后再试", ) if not verify_code(req.phone, req.code): raise HTTPException(status_code=400, detail="invalid sms code") return _finish_wechat_bind( db, openid=claims["openid"], wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], phone=req.phone, device_id=req.device_id, ) @router.post( "/wechat/bind-phone/jverify", response_model=WechatBindResultResponse, summary="微信登录·本机号(极光)绑定", ) def wechat_bind_phone_jverify( req: WechatBindPhoneJverifyRequest, db: DbSession ) -> WechatBindResultResponse: try: claims = decode_bind_ticket(req.bind_ticket) except TokenError as e: raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e try: phone = verify_and_get_phone(req.login_token) except JiguangError as e: logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True) raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e return _finish_wechat_bind( db, openid=claims["openid"], wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], phone=phone, device_id=req.device_id, ) # ===================== 微信占用冲突(M2) ===================== @router.post( "/wechat/conflict/continue", response_model=WechatBindResultResponse, summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)", ) def wechat_conflict_continue( req: WechatConflictContinueRequest, request: Request, db: DbSession ) -> WechatBindResultResponse: try: claims = decode_conflict_ticket(req.conflict_ticket) except TokenError as e: raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e enforce_rate_limit( request, scope="wechat-conflict-device", subject=req.device_id, limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试", ) user = user_repo.get_user_by_phone(db, claims["phone"]) if user is None: # P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走 raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录") if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") if user.wechat_openid is None: try: user_repo.attach_wechat_to_user( db, user, openid=claims["openid"], wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], ) except IntegrityError: db.rollback() # openid 被别处绑走 → 只登入不绑 user_repo.touch_last_login(db, user) else: user_repo.touch_last_login(db, user) # X 已绑别的微信 → 只登入,丢弃本次 openid completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6]) return WechatBindResultResponse( status="logged_in", token=_login_response(user, onboarding_completed=completed), ) @router.post( "/wechat/conflict/rebind", response_model=WechatBindResultResponse, summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)", ) def wechat_conflict_rebind( req: WechatConflictRebindRequest, request: Request, db: DbSession ) -> WechatBindResultResponse: from app.core.config import settings # 局部 import,避免循环 try: claims = decode_conflict_ticket(req.conflict_ticket) except TokenError as e: raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e enforce_rate_limit( request, scope="wechat-conflict-device", subject=req.device_id, limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试", ) phone = claims["phone"] if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS): days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑") user = user_repo.rebind_account( db, phone=phone, openid=claims["openid"], wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], ) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***", user.id, mask_phone(phone), claims["openid"][:6]) return WechatBindResultResponse( status="logged_in", token=_login_response(user, onboarding_completed=completed), ) # ===================== Refresh ===================== @router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对") def refresh(req: RefreshRequest, db: DbSession) -> TokenPair: try: payload = decode_token(req.refresh_token, expected_type="refresh") except TokenError as e: raise HTTPException(status_code=401, detail=str(e)) from e user_id = int(payload["sub"]) user = user_repo.get_user_by_id(db, user_id) if user is None or user.status != "active": raise HTTPException(status_code=401, detail="user not found or disabled") return TokenPair(**issue_token_pair(user.id)) # ===================== 当前用户 ===================== @router.get("/me", response_model=UserOut, summary="获取当前登录用户") def me(user: CurrentUser) -> UserOut: return UserOut.model_validate(user) # ===================== Logout ===================== @router.post("/logout", response_model=LogoutResponse, summary="登出(占位,客户端清 token)") def logout(user: CurrentUser) -> LogoutResponse: # 当前服务端无状态,真正的 token 失效靠客户端删 token。 # TODO: 加 jti 黑名单表后,这里把 access_token 的 jti 写黑名单 logger.info("logout user_id=%d", user.id) return LogoutResponse(ok=True)