Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

165 lines
5.9 KiB
Python

"""认证服务。"""
import hashlib
import secrets
import string
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import APIKey, CLIAuthSession, Department, User
from yuxi.utils.auth_utils import AuthUtils
from yuxi.utils.datetime_utils import utc_now_naive
CLI_AUTH_SESSION_TTL_SECONDS = 10 * 60
CLI_AUTH_POLL_INTERVAL_SECONDS = 2
CLI_AUTH_DEFAULT_KEY_NAME = "Yuxi CLI"
CLI_AUTH_USER_CODE_ALPHABET = "".join(ch for ch in string.ascii_uppercase + string.digits if ch not in "0O1I")
CLI_AUTH_STATUS_PENDING = "pending"
CLI_AUTH_STATUS_APPROVED = "approved"
CLI_AUTH_STATUS_CONSUMED = "consumed"
CLI_AUTH_STATUS_EXPIRED = "expired"
@dataclass
class CLIAuthError(Exception):
code: str
message: str
status_code: int = 400
def _hash_secret(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def _generate_device_code() -> str:
return f"yxcli_{secrets.token_urlsafe(32)}"
def _generate_user_code() -> str:
raw = "".join(secrets.choice(CLI_AUTH_USER_CODE_ALPHABET) for _ in range(8))
return f"{raw[:4]}-{raw[4:]}"
async def _generate_unique_user_code(db: AsyncSession) -> str:
for _ in range(10):
user_code = _generate_user_code()
result = await db.execute(select(CLIAuthSession.id).filter(CLIAuthSession.user_code == user_code))
if result.scalar_one_or_none() is None:
return user_code
raise RuntimeError("无法生成唯一 CLI 授权码")
def _expire_if_needed(session: CLIAuthSession, now=None) -> bool:
now = now or utc_now_naive()
if session.status in {CLI_AUTH_STATUS_PENDING, CLI_AUTH_STATUS_APPROVED} and session.expires_at <= now:
session.status = CLI_AUTH_STATUS_EXPIRED
return True
return False
async def create_cli_auth_session(db: AsyncSession, key_name: str | None = None) -> tuple[CLIAuthSession, str]:
device_code = _generate_device_code()
now = utc_now_naive()
session = CLIAuthSession(
device_code_hash=_hash_secret(device_code),
user_code=await _generate_unique_user_code(db),
status=CLI_AUTH_STATUS_PENDING,
key_name=(key_name or CLI_AUTH_DEFAULT_KEY_NAME).strip() or CLI_AUTH_DEFAULT_KEY_NAME,
created_at=now,
expires_at=now + timedelta(seconds=CLI_AUTH_SESSION_TTL_SECONDS),
)
db.add(session)
await db.commit()
await db.refresh(session)
return session, device_code
async def get_cli_auth_session_for_user(
db: AsyncSession, user_code: str, *, for_update: bool = False
) -> CLIAuthSession:
stmt = select(CLIAuthSession).filter(CLIAuthSession.user_code == user_code.strip().upper())
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
session = result.scalar_one_or_none()
if session is None:
raise CLIAuthError("not_found", "授权会话不存在", status_code=404)
if _expire_if_needed(session):
await db.commit()
raise CLIAuthError("expired_token", "授权会话已过期", status_code=410)
return session
async def approve_cli_auth_session(db: AsyncSession, user_code: str, user: User) -> CLIAuthSession:
session = await get_cli_auth_session_for_user(db, user_code, for_update=True)
if session.status == CLI_AUTH_STATUS_CONSUMED:
raise CLIAuthError("already_consumed", "授权会话已完成", status_code=409)
if session.status == CLI_AUTH_STATUS_APPROVED:
raise CLIAuthError("already_approved", "授权会话已批准", status_code=409)
if session.status != CLI_AUTH_STATUS_PENDING:
raise CLIAuthError("invalid_state", "授权会话状态无效", status_code=409)
session.status = CLI_AUTH_STATUS_APPROVED
session.approved_user_id = user.id
session.approved_at = utc_now_naive()
await db.commit()
await db.refresh(session)
return session
async def exchange_cli_auth_token(db: AsyncSession, device_code: str) -> dict:
result = await db.execute(
select(CLIAuthSession).filter(CLIAuthSession.device_code_hash == _hash_secret(device_code)).with_for_update()
)
session = result.scalar_one_or_none()
if session is None:
raise CLIAuthError("invalid_request", "授权会话不存在", status_code=404)
if _expire_if_needed(session):
await db.commit()
raise CLIAuthError("expired_token", "授权会话已过期", status_code=410)
if session.status == CLI_AUTH_STATUS_PENDING:
raise CLIAuthError("authorization_pending", "等待浏览器授权", status_code=400)
if session.status == CLI_AUTH_STATUS_CONSUMED:
raise CLIAuthError("already_consumed", "授权会话已完成", status_code=409)
if session.status != CLI_AUTH_STATUS_APPROVED or not session.approved_user_id:
raise CLIAuthError("invalid_state", "授权会话状态无效", status_code=409)
user_result = await db.execute(
select(User, Department.name)
.outerjoin(Department, User.department_id == Department.id)
.filter(User.id == session.approved_user_id, User.is_deleted == 0)
)
row = user_result.one_or_none()
if row is None:
raise CLIAuthError("invalid_user", "授权用户不存在", status_code=409)
user, department_name = row
full_key, key_hash, key_prefix = AuthUtils.generate_api_key()
api_key = APIKey(
key_hash=key_hash,
key_prefix=key_prefix,
name=session.key_name,
user_id=user.id,
created_by=str(user.id),
)
db.add(api_key)
await db.flush()
session.status = CLI_AUTH_STATUS_CONSUMED
session.api_key_id = api_key.id
session.consumed_at = utc_now_naive()
await db.commit()
await db.refresh(api_key)
user_data = user.to_dict()
user_data["department_name"] = department_name
return {
"api_key": api_key.to_dict(),
"secret": full_key,
"user": user_data,
}