PMFlow/backend/app/services/phone_auth_service.py

283 lines
9.5 KiB
Python

"""Phone-first authentication service."""
from __future__ import annotations
from datetime import timedelta
import hashlib
import os
import random
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import PhoneVerificationCode, User, UserStatus
from ..services.auth import AuthService
from ..services.auth.service import SECRET_KEY
from ..services.platform_access_service import get_platform_context
from ..services.workspace_service import (
accept_pending_invitations_for_phone,
ensure_personal_workspace,
list_user_workspaces,
)
from ..utils.timezone import now_cn_naive
PHONE_CODE_TTL_MINUTES = 10
PHONE_CODE_MAX_ATTEMPTS = 5
PHONE_AUTH_PURPOSES = {"login", "register", "accept_invitation", "bind_phone"}
def normalize_phone(country_code: str | None, phone_number: str | None) -> dict[str, str]:
code = str(country_code or "+86").strip()
if not code.startswith("+"):
code = f"+{code}"
code_digits = re.sub(r"\D", "", code)
code = f"+{code_digits}"
number = re.sub(r"\D", "", str(phone_number or ""))
if not code or code == "+":
raise HTTPException(status_code=400, detail="手机号国家区号无效")
if len(number) < 6 or len(number) > 15:
raise HTTPException(status_code=400, detail="手机号格式无效")
if code == "+86" and len(number) != 11:
raise HTTPException(status_code=400, detail="中国大陆手机号需要 11 位")
return {
"phone_country_code": code,
"phone_number": number,
"phone_e164": f"{code}{number}",
}
def mask_phone(phone_e164: str) -> str:
raw = str(phone_e164 or "")
if len(raw) <= 7:
return raw
return f"{raw[:3]}****{raw[-4:]}"
def _normalize_purpose(purpose: str | None) -> str:
value = str(purpose or "login").strip().lower()
return value if value in PHONE_AUTH_PURPOSES else "login"
def _hash_code(phone_e164: str, purpose: str, code: str) -> str:
payload = f"{phone_e164}:{purpose}:{code}:{SECRET_KEY}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _generate_code() -> str:
configured = str(os.getenv("PMFLOW_PHONE_OTP_DEV_CODE", "")).strip()
if configured and configured.isdigit() and len(configured) == 6:
return configured
return f"{random.randint(0, 999999):06d}"
def _should_return_dev_code() -> bool:
env = str(os.getenv("APP_ENV") or os.getenv("ENV") or os.getenv("NODE_ENV") or "development").lower()
return env not in {"production", "prod"}
async def send_phone_verification_code(
db: AsyncSession,
*,
country_code: str | None,
phone_number: str | None,
purpose: str | None = None,
request_ip: str | None = None,
) -> dict[str, Any]:
normalized = normalize_phone(country_code, phone_number)
normalized_purpose = _normalize_purpose(purpose)
code = _generate_code()
verification = PhoneVerificationCode(
phone_e164=normalized["phone_e164"],
purpose=normalized_purpose,
code_hash=_hash_code(normalized["phone_e164"], normalized_purpose, code),
expires_at=now_cn_naive() + timedelta(minutes=PHONE_CODE_TTL_MINUTES),
request_ip=request_ip,
)
db.add(verification)
await db.commit()
payload: dict[str, Any] = {
"phone_e164": normalized["phone_e164"],
"masked_phone": mask_phone(normalized["phone_e164"]),
"purpose": normalized_purpose,
"expires_in_seconds": PHONE_CODE_TTL_MINUTES * 60,
}
if _should_return_dev_code():
payload["dev_code"] = code
return payload
async def verify_phone_code(
db: AsyncSession,
*,
country_code: str | None,
phone_number: str | None,
code: str,
purpose: str | None = None,
) -> dict[str, str]:
normalized = normalize_phone(country_code, phone_number)
normalized_purpose = _normalize_purpose(purpose)
clean_code = str(code or "").strip()
if not re.fullmatch(r"\d{6}", clean_code):
raise HTTPException(status_code=400, detail="验证码格式无效")
verification = (
await db.execute(
select(PhoneVerificationCode)
.where(
and_(
PhoneVerificationCode.phone_e164 == normalized["phone_e164"],
PhoneVerificationCode.purpose == normalized_purpose,
PhoneVerificationCode.consumed_at.is_(None),
PhoneVerificationCode.expires_at >= now_cn_naive(),
)
)
.order_by(desc(PhoneVerificationCode.created_at), desc(PhoneVerificationCode.id))
.limit(1)
)
).scalar_one_or_none()
if not verification:
raise HTTPException(status_code=400, detail="验证码不存在或已过期")
if int(verification.attempt_count or 0) >= PHONE_CODE_MAX_ATTEMPTS:
raise HTTPException(status_code=429, detail="验证码尝试次数过多,请重新获取")
verification.attempt_count = int(verification.attempt_count or 0) + 1
if verification.code_hash != _hash_code(normalized["phone_e164"], normalized_purpose, clean_code):
await db.commit()
raise HTTPException(status_code=400, detail="验证码不正确")
verification.consumed_at = now_cn_naive()
await db.flush()
return normalized
def _phone_placeholder_email(phone_e164: str) -> str:
token = re.sub(r"\D", "", phone_e164)
return f"{token}@phone.pmflow.local"
def _phone_username(phone_e164: str) -> str:
token = re.sub(r"\D", "", phone_e164)
suffix = token[-4:] if token else "user"
digest = hashlib.sha1(phone_e164.encode("utf-8")).hexdigest()[:8]
return f"user_{suffix}_{digest}"
async def get_or_create_phone_user(
db: AsyncSession,
*,
phone_country_code: str,
phone_number: str,
phone_e164: str,
name: str | None = None,
) -> User:
user = (await db.execute(select(User).where(User.phone_e164 == phone_e164))).scalar_one_or_none()
if user:
user.phone_country_code = phone_country_code
user.phone_number = phone_number
user.phone_verified_at = user.phone_verified_at or now_cn_naive()
user.last_login_at = now_cn_naive()
user.login_method = "phone_otp"
await ensure_personal_workspace(db, user=user, commit=False)
return user
username = _phone_username(phone_e164)
existing_username = (await db.execute(select(User.id).where(User.username == username))).scalar_one_or_none()
if existing_username:
username = f"{username}_{random.randint(1000, 9999)}"
display_name = str(name or f"用户{phone_number[-4:]}").strip()
from ..models import Tenant, TenantMembership # avoid circular import in module load
from .workspace_service import ACTIVE_MEMBERSHIP_STATUS, PERSONAL_WORKSPACE_TYPE
tenant = Tenant(
name=f"{display_name} 的个人空间",
workspace_type=PERSONAL_WORKSPACE_TYPE,
slug=None,
owner_user_id=None,
status="active",
)
db.add(tenant)
await db.flush()
user = User(
tenant_id=int(tenant.id),
username=username,
password_hash=AuthService.hash_password(hashlib.sha256(os.urandom(32)).hexdigest()[:16]),
name=display_name,
email=_phone_placeholder_email(phone_e164),
phone_country_code=phone_country_code,
phone_number=phone_number,
phone_e164=phone_e164,
phone_verified_at=now_cn_naive(),
last_login_at=now_cn_naive(),
login_method="phone_otp",
role="OWNER",
assigned_domains=[],
workload=0,
status=UserStatus.ACTIVE.value,
is_active=True,
)
db.add(user)
await db.flush()
tenant.owner_user_id = int(user.id)
tenant.slug = f"personal-{int(user.id)}"
db.add(
TenantMembership(
tenant_id=int(tenant.id),
user_id=int(user.id),
role="OWNER",
status=ACTIVE_MEMBERSHIP_STATUS,
is_personal_owner=True,
)
)
return user
async def verify_phone_and_login(
db: AsyncSession,
*,
country_code: str | None,
phone_number: str | None,
code: str,
purpose: str | None = "login",
invite_token: str | None = None,
name: str | None = None,
) -> dict[str, Any]:
normalized = await verify_phone_code(
db,
country_code=country_code,
phone_number=phone_number,
code=code,
purpose=purpose,
)
user = await get_or_create_phone_user(db, name=name, **normalized)
accepted = []
if invite_token:
from .workspace_service import accept_invitation_by_verified_phone
accepted.append(
await accept_invitation_by_verified_phone(
db,
token=invite_token,
user=user,
phone_e164=normalized["phone_e164"],
switch_to_workspace=True,
)
)
else:
accepted = await accept_pending_invitations_for_phone(
db,
user=user,
phone_e164=normalized["phone_e164"],
auto_accept=False,
)
await db.commit()
await db.refresh(user)
access_token = AuthService.create_access_token(data={"sub": user.username})
workspaces = await list_user_workspaces(db, user=user)
platform_context = await get_platform_context(db, user=user)
return {
"access_token": access_token,
"token_type": "bearer",
"user": {**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context},
"pending_invitations": accepted,
}