283 lines
9.5 KiB
Python
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,
|
|
}
|