061f6baaf1
## 背景 / 需求 新增「微信登录」链路:用户用微信授权登录 App。openid 已绑账号即直接登入; 未绑则走「绑手机号」建号,并处理「手机号已被别的账号占用」的冲突(M2), 以及绑微信后展示昵称/头像的替换策略(M3 §10)。 关键取舍:微信登录**不套用**钱包提现里 bind-wechat 的「撞号即 409」逻辑 ——登录场景 openid 命中就应登入,不能因撞号把用户挡在门外。 ## 改动概览:新增 5 个端点(前缀 `/api/v1/auth`) | 方法 | 路径 | 作用 | |---|---|---| | POST | `/wechat-login` | code→openid。命中已绑用户→直接登入;未命中→签发 `bind_ticket`,返回 `need_bind_phone`(**此刻不建号**);未配 APP_ID/SECRET→503 | | POST | `/wechat/bind-phone/sms` | 持 `bind_ticket` + 手机号 + 短信码绑号 | | POST | `/wechat/bind-phone/jverify` | 持 `bind_ticket` + 极光本机号一键取号绑号 | | POST | `/wechat/conflict/continue` | 占用冲突·继续绑定=**登录老账号**(老号没绑微信则把 openid 绑上;已绑别的微信则只登入、丢弃本次 openid) | | POST | `/wechat/conflict/rebind` | 占用冲突·换绑=**单事务**注销老账号 X(腾号)+ 用该号重建新账号 Y + 写换绑台账;受 30 天限制 | 绑号两条取号路径(sms / jverify)尾段共用 `_finish_wechat_bind`: 手机号未注册→建微信账号(channel=wechat,昵称头像取微信)登入; 已被占用→返回 `phone_occupied`(带占用账号昵称/头像/注册时间/`has_wechat`、 `conflict_ticket`、`rebind_available`、`rebind_blocked_days`),交前端冲突页。 冲突页三选一,后端提供 continue / rebind 两个动作(「取消」为前端本地行为)。 ## 关键设计 **两种短时令牌(`core/security.py`,复用 `JWT_SECRET_KEY`,靠 `typ` 区分):** - `bind_ticket`(typ=wechat_bind,sub=openid,附微信昵称/头像,TTL 10min): openid 未命中时下发,覆盖「授权→输手机号→收码→验码」整个绑定流程。 - `conflict_ticket`(typ=wechat_conflict):比 bind_ticket **多编码已验证的手机号**; 换绑 / 继续绑定只认票里的 phone,堵住「拿别人手机号去夺号」的接管漏洞。 **30 天换绑限制:** 新增台账表 `phone_rebind_log`(手机号级、渠道无关), 记录「腾号重建」这一破坏性事件,靠 `rebound_at` 算窗口; 阈值走配置 `PHONE_REBIND_LIMIT_DAYS`。命中限制的换绑请求返回 409。 **M3 §10 昵称/头像替换:** 绑微信默认用微信昵称/头像替换展示, 抽成共享 helper,登录绑号路径与钱包绑微信路径两处共用 (故 `wallet.py`、`tests/test_withdraw.py` 一并有改动)。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #139
194 lines
7.5 KiB
Python
194 lines
7.5 KiB
Python
"""JWT 签发与校验。
|
|
|
|
设计:
|
|
- access_token : 短期(默认 2 小时),每个业务请求 Bearer 携带
|
|
- refresh_token: 长期(默认 30 天),过期时 POST /refresh 换新的 access
|
|
两者结构一致(都是 JWT),靠 payload 里的 `typ` 字段区分,防止用 refresh 当 access 用。
|
|
|
|
为什么不用 OAuth2 + session 表?
|
|
- 占坑 + 早期阶段,JWT 自包含、零状态,够用
|
|
- 后续需要"主动作废"(改密码/账号被盗)时再加 jti 黑名单表
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Literal
|
|
|
|
import bcrypt
|
|
import jwt
|
|
|
|
from app.core.config import settings
|
|
|
|
TokenType = Literal["access", "refresh"]
|
|
|
|
|
|
class TokenError(Exception):
|
|
"""token 解析/校验失败时统一抛这个,api 层捕获后返回 401。"""
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def create_token(*, user_id: int, token_type: TokenType) -> tuple[str, datetime]:
|
|
"""签发 token,返回 (token_string, expires_at_utc)。"""
|
|
now = _now()
|
|
if token_type == "access":
|
|
expire = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
else:
|
|
expire = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
|
|
|
payload: dict[str, Any] = {
|
|
"sub": str(user_id),
|
|
"typ": token_type,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int(expire.timestamp()),
|
|
}
|
|
token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
|
return token, expire
|
|
|
|
|
|
def decode_token(token: str, *, expected_type: TokenType | None = None) -> dict[str, Any]:
|
|
"""解析并校验 token。
|
|
|
|
- 签名错/过期 → TokenError
|
|
- expected_type 不匹配 → TokenError (防 refresh 当 access 用)
|
|
"""
|
|
try:
|
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
|
except jwt.ExpiredSignatureError as e:
|
|
raise TokenError("token expired") from e
|
|
except jwt.InvalidTokenError as e:
|
|
raise TokenError(f"invalid token: {e}") from e
|
|
|
|
if expected_type is not None and payload.get("typ") != expected_type:
|
|
raise TokenError(f"wrong token type: want={expected_type} got={payload.get('typ')}")
|
|
|
|
if "sub" not in payload:
|
|
raise TokenError("token missing sub")
|
|
|
|
return payload
|
|
|
|
|
|
def issue_token_pair(user_id: int) -> dict[str, Any]:
|
|
"""登录/刷新成功后调,一次性发回 access + refresh。
|
|
|
|
返回字段对齐 OAuth2 习惯,前端易接:
|
|
access_token / refresh_token / token_type / expires_in
|
|
"""
|
|
access, access_exp = create_token(user_id=user_id, token_type="access")
|
|
refresh, refresh_exp = create_token(user_id=user_id, token_type="refresh")
|
|
return {
|
|
"access_token": access,
|
|
"refresh_token": refresh,
|
|
"token_type": "Bearer",
|
|
"expires_in": int((access_exp - _now()).total_seconds()),
|
|
"refresh_expires_in": int((refresh_exp - _now()).total_seconds()),
|
|
}
|
|
|
|
|
|
def create_bind_ticket(
|
|
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
|
|
) -> str:
|
|
"""微信登录未命中 openid 时,签发短时"待绑手机"令牌,承载 openid + 微信昵称头像。
|
|
|
|
typ='wechat_bind'、sub=openid;有效期 settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES 分钟。
|
|
与 access/refresh 用同一 JWT_SECRET_KEY 签名,靠 typ 区分,decode_bind_ticket 校验 typ。
|
|
"""
|
|
now = _now()
|
|
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
|
|
payload: dict[str, Any] = {
|
|
"sub": openid,
|
|
"typ": "wechat_bind",
|
|
"wnk": wechat_nickname,
|
|
"wav": wechat_avatar_url,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int(expire.timestamp()),
|
|
}
|
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
|
|
|
|
|
def decode_bind_ticket(token: str) -> dict[str, Any]:
|
|
"""解析"待绑手机"令牌,校验签名/过期/类型。失败抛 TokenError。
|
|
|
|
返回 {'openid': str, 'wnk': str|None, 'wav': str|None}。
|
|
"""
|
|
try:
|
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
|
except jwt.ExpiredSignatureError as e:
|
|
raise TokenError("bind ticket expired") from e
|
|
except jwt.InvalidTokenError as e:
|
|
raise TokenError(f"invalid bind ticket: {e}") from e
|
|
if payload.get("typ") != "wechat_bind":
|
|
raise TokenError(f"wrong token type: want=wechat_bind got={payload.get('typ')}")
|
|
if "sub" not in payload:
|
|
raise TokenError("bind ticket missing sub")
|
|
return {"openid": payload["sub"], "wnk": payload.get("wnk"), "wav": payload.get("wav")}
|
|
|
|
|
|
def create_conflict_ticket(
|
|
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None, phone: str
|
|
) -> str:
|
|
"""手机号占用时签发的短时"冲突处理"令牌。
|
|
|
|
比 bind_ticket 多编码 **已验证的手机号 phone** —— 换绑/继续绑定只认它,证明"这对
|
|
openid/手机号刚在绑号时验证通过",免用户重输验证码,又堵住"拿自己 openid + 任意手机号
|
|
去夺号"的接管漏洞。typ='wechat_conflict';有效期复用 WECHAT_BIND_TICKET_EXPIRE_MINUTES。
|
|
"""
|
|
now = _now()
|
|
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
|
|
payload: dict[str, Any] = {
|
|
"sub": openid,
|
|
"typ": "wechat_conflict",
|
|
"wnk": wechat_nickname,
|
|
"wav": wechat_avatar_url,
|
|
"phn": phone,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int(expire.timestamp()),
|
|
}
|
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
|
|
|
|
|
def decode_conflict_ticket(token: str) -> dict[str, Any]:
|
|
"""解析"冲突处理"令牌,校验签名/过期/类型。失败抛 TokenError。
|
|
|
|
返回 {'openid': str, 'wnk': str|None, 'wav': str|None, 'phone': str}。
|
|
"""
|
|
try:
|
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
|
except jwt.ExpiredSignatureError as e:
|
|
raise TokenError("conflict ticket expired") from e
|
|
except jwt.InvalidTokenError as e:
|
|
raise TokenError(f"invalid conflict ticket: {e}") from e
|
|
if payload.get("typ") != "wechat_conflict":
|
|
raise TokenError(f"wrong token type: want=wechat_conflict got={payload.get('typ')}")
|
|
if "sub" not in payload or "phn" not in payload:
|
|
raise TokenError("conflict ticket missing sub/phn")
|
|
return {
|
|
"openid": payload["sub"],
|
|
"wnk": payload.get("wnk"),
|
|
"wav": payload.get("wav"),
|
|
"phone": payload["phn"],
|
|
}
|
|
|
|
|
|
# ===================== 密码 hash(admin 后台账号用)=====================
|
|
# 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。
|
|
|
|
def hash_password(plain: str) -> str:
|
|
"""bcrypt 加盐 hash,返回可入库的字符串。
|
|
|
|
bcrypt 限制明文 ≤72 字节,超出抛 ValueError(实测 bcrypt 5.0:25 个中文=75 字节即触发)。
|
|
统一截断到 72 字节(bcrypt 标准做法)——72 字节仍是强密码,verify 同样截断保证一致。
|
|
"""
|
|
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")
|
|
|
|
|
|
def verify_password(plain: str, hashed: str) -> bool:
|
|
"""校验明文与 bcrypt hash。明文同样截断 72 字节(与 hash_password 一致,否则超长密码永远不匹配);
|
|
hash 串损坏/格式非法时返回 False,不抛异常。"""
|
|
try:
|
|
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8"))
|
|
except (ValueError, TypeError):
|
|
return False
|