mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
738 lines
32 KiB
Python
738 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import sqlalchemy
|
|
import argon2
|
|
import jwt
|
|
import datetime
|
|
import typing
|
|
import asyncio
|
|
import dataclasses
|
|
import hashlib
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from ....entity.persistence import user
|
|
from ....utils import constants
|
|
from ....entity.errors import account as account_errors
|
|
from ....workspace.collaboration import normalize_email
|
|
from ..authz import Permission, permissions_for_role
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from ....core.app import Application
|
|
|
|
|
|
class AccountExistsLoginRequiredError(ValueError):
|
|
code = 'account_exists_login_required'
|
|
|
|
|
|
class PublicRegistrationClosedError(ValueError):
|
|
code = 'registration_closed'
|
|
|
|
|
|
class ControlPlaneDirectoryRequiredError(PublicRegistrationClosedError):
|
|
code = 'control_plane_required'
|
|
|
|
|
|
class AccountDisabledError(ValueError):
|
|
code = 'account_disabled'
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
class SpaceOAuthStateConsumption:
|
|
purpose: typing.Literal['login', 'bind']
|
|
account: user.User | None
|
|
launch_workspace_uuid: str | None = None
|
|
|
|
|
|
class UserService:
|
|
ap: Application
|
|
_create_user_lock: asyncio.Lock
|
|
|
|
def __init__(self, ap: Application) -> None:
|
|
self.ap = ap
|
|
self._create_user_lock = asyncio.Lock()
|
|
self._password_hash_lock = asyncio.Semaphore(1)
|
|
self._space_oauth_state_lock = asyncio.Lock()
|
|
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
|
|
|
|
@staticmethod
|
|
def _space_oauth_state_digest(state: str) -> str:
|
|
return hashlib.sha256(state.encode('utf-8')).hexdigest()
|
|
|
|
async def issue_space_oauth_state(
|
|
self,
|
|
purpose: typing.Literal['login', 'bind'],
|
|
*,
|
|
account_uuid: str | None = None,
|
|
launch_workspace_uuid: str | None = None,
|
|
ttl_seconds: int = 600,
|
|
) -> str:
|
|
"""Issue an opaque, single-use OAuth state without exposing a JWT."""
|
|
if purpose == 'bind' and not account_uuid:
|
|
raise ValueError('An Account is required for Space binding')
|
|
if purpose == 'login' and account_uuid is not None:
|
|
raise ValueError('Login state cannot be bound to an Account')
|
|
if purpose != 'login' and launch_workspace_uuid is not None:
|
|
raise ValueError('Launch Workspace state is only valid for Space login')
|
|
if ttl_seconds <= 0:
|
|
raise ValueError('OAuth state lifetime must be positive')
|
|
|
|
raw_state = secrets.token_urlsafe(32)
|
|
digest = self._space_oauth_state_digest(raw_state)
|
|
expires_at = time.monotonic() + min(ttl_seconds, 600)
|
|
async with self._space_oauth_state_lock:
|
|
now = time.monotonic()
|
|
self._space_oauth_states = {key: value for key, value in self._space_oauth_states.items() if value[2] > now}
|
|
if len(self._space_oauth_states) >= 4096:
|
|
oldest = min(self._space_oauth_states, key=lambda key: self._space_oauth_states[key][2])
|
|
self._space_oauth_states.pop(oldest, None)
|
|
self._space_oauth_states[digest] = (purpose, account_uuid, expires_at, launch_workspace_uuid)
|
|
return raw_state
|
|
|
|
async def consume_space_oauth_state_details(
|
|
self,
|
|
raw_state: str,
|
|
purpose: typing.Literal['login', 'bind'],
|
|
) -> SpaceOAuthStateConsumption:
|
|
"""Atomically consume OAuth state and return any bound launch intent."""
|
|
if not isinstance(raw_state, str) or not raw_state:
|
|
raise ValueError('Invalid or expired OAuth state')
|
|
digest = self._space_oauth_state_digest(raw_state)
|
|
async with self._space_oauth_state_lock:
|
|
entry = self._space_oauth_states.pop(digest, None)
|
|
if entry is None or entry[0] != purpose or entry[2] <= time.monotonic():
|
|
raise ValueError('Invalid or expired OAuth state')
|
|
if purpose == 'login':
|
|
return SpaceOAuthStateConsumption(
|
|
purpose='login',
|
|
account=None,
|
|
launch_workspace_uuid=entry[3],
|
|
)
|
|
|
|
account_uuid = entry[1]
|
|
account = await self.get_user_by_uuid(account_uuid or '')
|
|
if account is None:
|
|
raise ValueError('Invalid or expired OAuth state')
|
|
self._require_active_account(account)
|
|
return SpaceOAuthStateConsumption(purpose='bind', account=account)
|
|
|
|
async def consume_space_oauth_state(
|
|
self,
|
|
raw_state: str,
|
|
purpose: typing.Literal['login', 'bind'],
|
|
) -> user.User | None:
|
|
"""Atomically consume OAuth state and resolve its active bind Account."""
|
|
consumed = await self.consume_space_oauth_state_details(raw_state, purpose)
|
|
return consumed.account
|
|
|
|
async def _hash_password(self, password: str) -> str:
|
|
async with self._password_hash_lock:
|
|
return await asyncio.to_thread(argon2.PasswordHasher().hash, password)
|
|
|
|
def _require_local_directory(self) -> None:
|
|
if self._uses_control_plane_directory():
|
|
raise ControlPlaneDirectoryRequiredError(
|
|
'Cloud Accounts and directory changes are managed by the SaaS control plane'
|
|
)
|
|
|
|
def _uses_control_plane_directory(self) -> bool:
|
|
workspace_service = getattr(self.ap, 'workspace_service', None)
|
|
return bool(workspace_service is not None and workspace_service.policy.multi_workspace_enabled)
|
|
|
|
async def _verify_password(self, hashed_password: str, password: str) -> None:
|
|
async with self._password_hash_lock:
|
|
await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password)
|
|
|
|
async def _update_space_provider_for_account(self, account: typing.Any, api_key: str) -> None:
|
|
"""Refresh the OSS Workspace Space provider without guessing a SaaS Workspace.
|
|
|
|
Space OAuth credentials belong to an Account, while model-provider secrets
|
|
belong to a Workspace. Community edition has one unambiguous Workspace, so
|
|
the historical automatic refresh remains available to members allowed to
|
|
manage provider secrets. In multi-Workspace SaaS mode the OAuth callback has
|
|
no trusted Workspace selector; the closed control plane or an explicit
|
|
Workspace settings action must perform that linkage instead.
|
|
"""
|
|
|
|
workspace_service = getattr(self.ap, 'workspace_service', None)
|
|
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
|
account_uuid = getattr(account, 'uuid', None)
|
|
if workspace_service is None or collaboration_service is None or not isinstance(account_uuid, str):
|
|
# Never turn a missing tenant kernel into a global secret mutation.
|
|
return
|
|
if workspace_service.policy.multi_workspace_enabled:
|
|
return
|
|
|
|
accesses = await collaboration_service.list_account_workspaces(account_uuid)
|
|
if len(accesses) != 1:
|
|
return
|
|
access = accesses[0]
|
|
if Permission.PROVIDER_SECRET_MANAGE.value not in permissions_for_role(access.membership.role):
|
|
return
|
|
await self.ap.provider_service.update_space_model_provider_api_keys(
|
|
access.workspace.uuid,
|
|
api_key,
|
|
)
|
|
|
|
async def is_initialized(self) -> bool:
|
|
account = await self._identity_scalar(
|
|
sqlalchemy.select(user.User).limit(1),
|
|
f'instance:{self._jwt_identity()[1]}',
|
|
)
|
|
return account is not None
|
|
|
|
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
|
|
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
|
|
|
|
def _jwt_identity(self) -> tuple[str, str]:
|
|
workspace_service = getattr(self.ap, 'workspace_service', None)
|
|
instance_uuid = str(getattr(workspace_service, 'instance_uuid', '') or constants.instance_id).strip()
|
|
# UserService is constructed only after config/bootstrap in production.
|
|
# The fallback keeps lightweight isolated unit tests deterministic.
|
|
if not instance_uuid:
|
|
instance_uuid = 'uninitialized-test-instance'
|
|
return 'langbot-core', f'langbot-instance:{instance_uuid}'
|
|
|
|
def _legacy_local_tokens_allowed(self) -> bool:
|
|
workspace_service = getattr(self.ap, 'workspace_service', None)
|
|
policy = getattr(workspace_service, 'policy', None)
|
|
return getattr(policy, 'multi_workspace_enabled', False) is not True
|
|
|
|
async def create_user(self, user_email: str, password: str) -> None:
|
|
"""Create the first local Account and Workspace owner atomically."""
|
|
|
|
await self.create_initial_account(user_email, password)
|
|
|
|
async def create_initial_account(self, user_email: str, password: str) -> user.User:
|
|
self._require_local_directory()
|
|
normalized_email = normalize_email(user_email)
|
|
hashed_password = await self._hash_password(password)
|
|
|
|
async with self._create_user_lock:
|
|
async with self._session_factory()() as session:
|
|
async with session.begin():
|
|
existing_count = int(
|
|
(await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(user.User))) or 0
|
|
)
|
|
if existing_count:
|
|
raise PublicRegistrationClosedError('System already initialized')
|
|
account = self._new_account(normalized_email, hashed_password)
|
|
session.add(account)
|
|
await session.flush()
|
|
await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
|
|
return account
|
|
|
|
async def register_invited_account(
|
|
self,
|
|
invitation_token: str,
|
|
user_email: str,
|
|
password: str,
|
|
) -> tuple[user.User, typing.Any, str]:
|
|
"""Create an invited Account and accept its Membership in one transaction."""
|
|
|
|
normalized_email = normalize_email(user_email)
|
|
if self._uses_control_plane_directory():
|
|
raise ControlPlaneDirectoryRequiredError(
|
|
'Cloud invitation registration must use a Space account to preserve control-plane identity'
|
|
)
|
|
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
|
|
if invitation.normalized_email != normalized_email:
|
|
from ....workspace.collaboration import InvitationEmailMismatchError
|
|
|
|
raise InvitationEmailMismatchError('Invitation email does not match the Account')
|
|
hashed_password = await self._hash_password(password)
|
|
|
|
async with self._create_user_lock:
|
|
async with self._session_factory()() as session:
|
|
async with session.begin():
|
|
existing = await session.scalar(
|
|
sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email)
|
|
)
|
|
if existing is not None:
|
|
raise AccountExistsLoginRequiredError('An Account already exists for this email')
|
|
account = self._new_account(normalized_email, hashed_password)
|
|
session.add(account)
|
|
await session.flush()
|
|
membership = await self.ap.workspace_collaboration_service.accept_invitation(
|
|
invitation_token,
|
|
account.uuid,
|
|
session=session,
|
|
)
|
|
token = await self.generate_jwt_token(account)
|
|
return account, membership, token
|
|
|
|
def _new_account(self, normalized_email: str, hashed_password: str) -> user.User:
|
|
return user.User(
|
|
uuid=str(uuid.uuid4()),
|
|
user=normalized_email,
|
|
normalized_email=normalized_email,
|
|
password=hashed_password,
|
|
account_type='local',
|
|
status=user.AccountStatus.ACTIVE.value,
|
|
source=user.AccountSource.LOCAL.value,
|
|
projection_revision=0,
|
|
)
|
|
|
|
async def get_user_by_email(self, user_email: str) -> user.User | None:
|
|
normalized_email = user_email.strip().casefold()
|
|
return await self._identity_scalar(
|
|
sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email),
|
|
f'email:{normalized_email}',
|
|
)
|
|
|
|
async def get_user_by_uuid(self, account_uuid: str) -> user.User | None:
|
|
return await self._identity_scalar(
|
|
sqlalchemy.select(user.User).where(user.User.uuid == account_uuid),
|
|
f'uuid:{account_uuid}',
|
|
)
|
|
|
|
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
|
|
"""Get user by Space account UUID"""
|
|
return await self._identity_scalar(
|
|
sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid),
|
|
f'space:{space_account_uuid}',
|
|
)
|
|
|
|
async def authenticate(self, user_email: str, password: str) -> str | None:
|
|
user_obj = await self.get_user_by_email(user_email)
|
|
if user_obj is None:
|
|
raise ValueError('用户不存在')
|
|
self._require_active_account(user_obj)
|
|
|
|
# Check if this user has a local password set
|
|
if not user_obj.password:
|
|
raise ValueError('请使用 Space 账户登录')
|
|
|
|
await self._verify_password(user_obj.password, password)
|
|
|
|
return await self.generate_jwt_token(user_obj)
|
|
|
|
async def generate_jwt_token(self, account: user.User | str) -> str:
|
|
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
|
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
|
|
|
|
account_obj: user.User | None = account if not isinstance(account, str) and hasattr(account, 'user') else None
|
|
user_email = account_obj.user if account_obj is not None else account
|
|
if account_obj is None and hasattr(self.ap, 'persistence_mgr'):
|
|
try:
|
|
account_obj = await self.get_user_by_email(user_email)
|
|
except (AttributeError, TypeError):
|
|
# Lightweight unit-test and bootstrap callers may not have persistence wired.
|
|
account_obj = None
|
|
|
|
payload = {
|
|
'user': user_email,
|
|
'iss': self._jwt_identity()[0],
|
|
'aud': self._jwt_identity()[1],
|
|
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire),
|
|
}
|
|
if account_obj is not None:
|
|
self._require_active_account(account_obj)
|
|
payload.update(
|
|
{
|
|
'sub': account_obj.uuid,
|
|
'account_revision': account_obj.projection_revision,
|
|
}
|
|
)
|
|
|
|
return jwt.encode(payload, jwt_secret, algorithm='HS256')
|
|
|
|
async def verify_jwt_token(self, token: str) -> str:
|
|
account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
|
|
if isinstance(account, str):
|
|
return account
|
|
return account.user
|
|
|
|
async def get_authenticated_account(
|
|
self,
|
|
token: str,
|
|
*,
|
|
allow_unresolved_legacy: bool = False,
|
|
) -> user.User | str:
|
|
"""Resolve a JWT to an active Account, accepting bounded legacy email tokens."""
|
|
|
|
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
|
issuer, audience = self._jwt_identity()
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
jwt_secret,
|
|
algorithms=['HS256'],
|
|
issuer=issuer,
|
|
audience=audience,
|
|
options={'require': ['exp', 'iss', 'aud']},
|
|
)
|
|
except jwt.MissingRequiredClaimError:
|
|
# Preserve one bounded OSS upgrade path for previously issued
|
|
# community tokens. SaaS/Cloud policy never accepts these tokens,
|
|
# and a token carrying a new-style or foreign audience cannot fall
|
|
# back into the legacy decoder.
|
|
unverified = jwt.decode(token, options={'verify_signature': False})
|
|
if (
|
|
not self._legacy_local_tokens_allowed()
|
|
or 'aud' in unverified
|
|
or unverified.get('iss') != 'LangBot-community'
|
|
):
|
|
raise
|
|
payload = jwt.decode(
|
|
token,
|
|
jwt_secret,
|
|
algorithms=['HS256'],
|
|
options={'require': ['exp'], 'verify_aud': False, 'verify_iss': False},
|
|
)
|
|
account_obj: user.User | None = None
|
|
account_uuid = payload.get('sub')
|
|
if isinstance(account_uuid, str) and account_uuid:
|
|
try:
|
|
account_obj = await self.get_user_by_uuid(account_uuid)
|
|
except AttributeError:
|
|
account_obj = None
|
|
if account_obj is None:
|
|
legacy_email = payload.get('user')
|
|
if not isinstance(legacy_email, str) or not legacy_email:
|
|
raise ValueError('JWT Account identity is missing')
|
|
try:
|
|
account_obj = await self.get_user_by_email(legacy_email)
|
|
except AttributeError:
|
|
account_obj = None
|
|
if account_obj is None and allow_unresolved_legacy:
|
|
return legacy_email
|
|
if account_obj is None:
|
|
raise ValueError('Account not found')
|
|
self._require_active_account(account_obj)
|
|
token_revision = payload.get('account_revision')
|
|
if token_revision is not None and int(token_revision) != account_obj.projection_revision:
|
|
raise ValueError('Account token revision is stale')
|
|
return account_obj
|
|
|
|
@staticmethod
|
|
def _require_active_account(account: user.User) -> None:
|
|
status = getattr(account, 'status', user.AccountStatus.ACTIVE.value)
|
|
if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value:
|
|
raise AccountDisabledError('Account is disabled')
|
|
|
|
async def reset_password(self, user_email: str, new_password: str) -> None:
|
|
hashed_password = await self._hash_password(new_password)
|
|
normalized_email = normalize_email(user_email)
|
|
|
|
await self._identity_execute(
|
|
sqlalchemy.update(user.User)
|
|
.where(user.User.normalized_email == normalized_email)
|
|
.values(password=hashed_password),
|
|
f'email:{normalized_email}',
|
|
)
|
|
|
|
async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:
|
|
user_obj = await self.get_user_by_email(user_email)
|
|
if user_obj is None:
|
|
raise ValueError('User not found')
|
|
|
|
if not user_obj.password:
|
|
raise ValueError('No local password set, please set a password first')
|
|
|
|
await self._verify_password(user_obj.password, current_password)
|
|
|
|
hashed_password = await self._hash_password(new_password)
|
|
normalized_email = normalize_email(user_email)
|
|
|
|
await self._identity_execute(
|
|
sqlalchemy.update(user.User)
|
|
.where(user.User.normalized_email == normalized_email)
|
|
.values(password=hashed_password),
|
|
f'email:{normalized_email}',
|
|
)
|
|
|
|
# Space user management
|
|
|
|
async def create_or_update_space_user(
|
|
self,
|
|
space_account_uuid: str,
|
|
email: str,
|
|
access_token: str,
|
|
refresh_token: str,
|
|
api_key: str,
|
|
expires_in: int = 0,
|
|
) -> user.User:
|
|
"""Create or update a Space user account (only if system not initialized or user exists)"""
|
|
if self._uses_control_plane_directory():
|
|
return await self._update_projected_space_user(
|
|
space_account_uuid=space_account_uuid,
|
|
email=email,
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
api_key=api_key,
|
|
expires_in=expires_in,
|
|
)
|
|
self._require_local_directory()
|
|
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
|
|
|
async with self._create_user_lock:
|
|
# Check if user with this Space UUID already exists
|
|
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
|
|
|
if existing_user:
|
|
# Update existing user's tokens
|
|
await self._identity_execute(
|
|
sqlalchemy.update(user.User)
|
|
.where(user.User.space_account_uuid == space_account_uuid)
|
|
.values(
|
|
space_access_token=access_token,
|
|
space_refresh_token=refresh_token,
|
|
space_api_key=api_key,
|
|
space_access_token_expires_at=expires_at,
|
|
),
|
|
f'space:{space_account_uuid}',
|
|
)
|
|
await self._update_space_provider_for_account(existing_user, api_key)
|
|
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
|
|
|
# Check if user with same email exists
|
|
existing_email_user = await self.get_user_by_email(email)
|
|
if existing_email_user:
|
|
# Email is display/contact identity, not an OAuth subject. An
|
|
# unknown Space subject must never take over an existing local
|
|
# Account merely by presenting the same email. The Account
|
|
# owner must first authenticate locally and use the explicit,
|
|
# account-bound bind flow.
|
|
raise account_errors.AccountEmailMismatchError()
|
|
|
|
# Check if system is already initialized
|
|
is_initialized = await self.is_initialized()
|
|
if is_initialized:
|
|
raise account_errors.AccountEmailMismatchError()
|
|
|
|
# Create new Space user (first time initialization)
|
|
if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'):
|
|
async with self._session_factory()() as session:
|
|
async with session.begin():
|
|
account = user.User(
|
|
uuid=str(uuid.uuid4()),
|
|
user=normalize_email(email),
|
|
normalized_email=normalize_email(email),
|
|
password='',
|
|
account_type='space',
|
|
status=user.AccountStatus.ACTIVE.value,
|
|
source=user.AccountSource.LOCAL.value,
|
|
projection_revision=0,
|
|
space_account_uuid=space_account_uuid,
|
|
space_access_token=access_token,
|
|
space_refresh_token=refresh_token,
|
|
space_api_key=api_key,
|
|
space_access_token_expires_at=expires_at,
|
|
)
|
|
session.add(account)
|
|
await session.flush()
|
|
await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
|
|
else:
|
|
# Compatibility path for lightweight service tests without a real engine.
|
|
await self.ap.persistence_mgr.execute_async(
|
|
sqlalchemy.insert(user.User).values(
|
|
user=normalize_email(email),
|
|
normalized_email=normalize_email(email),
|
|
password='',
|
|
account_type='space',
|
|
space_account_uuid=space_account_uuid,
|
|
space_access_token=access_token,
|
|
space_refresh_token=refresh_token,
|
|
space_api_key=api_key,
|
|
space_access_token_expires_at=expires_at,
|
|
)
|
|
)
|
|
created_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
|
if created_user is not None:
|
|
await self._update_space_provider_for_account(created_user, api_key)
|
|
return created_user
|
|
|
|
async def _update_projected_space_user(
|
|
self,
|
|
*,
|
|
space_account_uuid: str,
|
|
email: str,
|
|
access_token: str,
|
|
refresh_token: str,
|
|
api_key: str,
|
|
expires_in: int,
|
|
) -> user.User:
|
|
"""Attach OAuth credentials to an already projected Cloud Account."""
|
|
|
|
normalized_email = normalize_email(email)
|
|
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
|
async with self._create_user_lock:
|
|
projected = await self.get_user_by_space_account_uuid(space_account_uuid)
|
|
if (
|
|
projected is None
|
|
or projected.uuid != space_account_uuid
|
|
or projected.normalized_email != normalized_email
|
|
or projected.source != user.AccountSource.CLOUD_PROJECTION.value
|
|
or projected.account_type != 'space'
|
|
):
|
|
raise ControlPlaneDirectoryRequiredError('Space Account is not present in the verified Cloud directory')
|
|
self._require_active_account(projected)
|
|
await self._identity_execute(
|
|
sqlalchemy.update(user.User)
|
|
.where(
|
|
user.User.uuid == projected.uuid,
|
|
user.User.space_account_uuid == space_account_uuid,
|
|
user.User.source == user.AccountSource.CLOUD_PROJECTION.value,
|
|
)
|
|
.values(
|
|
space_access_token=access_token,
|
|
space_refresh_token=refresh_token,
|
|
space_api_key=api_key,
|
|
space_access_token_expires_at=expires_at,
|
|
),
|
|
f'space:{space_account_uuid}',
|
|
)
|
|
refreshed = await self.get_user_by_space_account_uuid(space_account_uuid)
|
|
if refreshed is None:
|
|
raise ControlPlaneDirectoryRequiredError('Space Account disappeared from the verified Cloud directory')
|
|
self._require_active_account(refreshed)
|
|
return refreshed
|
|
|
|
async def authenticate_space_user(
|
|
self, access_token: str, refresh_token: str, expires_in: int = 0
|
|
) -> typing.Tuple[str, user.User]:
|
|
"""Authenticate with Space and return JWT token"""
|
|
# Get user info from Space using raw API (token just obtained, no need to validate)
|
|
user_info = await self.ap.space_service.get_user_info_raw(access_token)
|
|
|
|
account = user_info.get('account', {})
|
|
api_key = user_info.get('api_key', '')
|
|
|
|
space_account_uuid = account.get('uuid')
|
|
email = account.get('email')
|
|
|
|
if not space_account_uuid or not email:
|
|
raise ValueError('Invalid Space user info')
|
|
|
|
# Create or update Space user in local database
|
|
user_obj = await self.create_or_update_space_user(
|
|
space_account_uuid=space_account_uuid,
|
|
email=email,
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
api_key=api_key,
|
|
expires_in=expires_in,
|
|
)
|
|
|
|
# Generate JWT token
|
|
jwt_token = await self.generate_jwt_token(user_obj)
|
|
|
|
return jwt_token, user_obj
|
|
|
|
async def get_first_user(self) -> user.User | None:
|
|
"""Get the first user (for single-user mode)"""
|
|
return await self._identity_scalar(
|
|
sqlalchemy.select(user.User).limit(1),
|
|
f'instance:{self._jwt_identity()[1]}',
|
|
)
|
|
|
|
async def _identity_scalar(
|
|
self,
|
|
statement: typing.Any,
|
|
identity: str,
|
|
) -> user.User | None:
|
|
"""Execute one exact Account lookup in an explicit discovery transaction."""
|
|
|
|
digest = hashlib.sha256(identity.encode('utf-8')).hexdigest()
|
|
current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
|
|
identity_uow = getattr(self.ap.persistence_mgr, 'identity_discovery_uow', None)
|
|
if current_session() is None and callable(identity_uow):
|
|
async with identity_uow(digest) as discovery:
|
|
return await discovery.session.scalar(statement)
|
|
result = await self.ap.persistence_mgr.execute_async(statement)
|
|
rows = result.all()
|
|
return rows[0] if rows else None
|
|
|
|
async def _identity_execute(self, statement: typing.Any, identity: str) -> typing.Any:
|
|
"""Execute one exact Account mutation in an explicit transaction."""
|
|
|
|
digest = hashlib.sha256(identity.encode('utf-8')).hexdigest()
|
|
current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
|
|
identity_uow = getattr(self.ap.persistence_mgr, 'identity_discovery_uow', None)
|
|
if current_session() is None and callable(identity_uow):
|
|
async with identity_uow(digest) as discovery:
|
|
return await discovery.session.execute(statement)
|
|
return await self.ap.persistence_mgr.execute_async(statement)
|
|
|
|
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
|
|
"""Set or change password for a user"""
|
|
user_obj = await self.get_user_by_email(user_email)
|
|
|
|
if user_obj is None:
|
|
raise ValueError('User not found')
|
|
|
|
# If user already has a password, verify current password
|
|
has_password = bool(user_obj.password and user_obj.password.strip())
|
|
if has_password:
|
|
if not current_password:
|
|
raise ValueError('Current password is required')
|
|
await self._verify_password(user_obj.password, current_password)
|
|
|
|
hashed_password = await self._hash_password(new_password)
|
|
normalized_email = normalize_email(user_email)
|
|
await self._identity_execute(
|
|
sqlalchemy.update(user.User)
|
|
.where(user.User.normalized_email == normalized_email)
|
|
.values(password=hashed_password),
|
|
f'email:{normalized_email}',
|
|
)
|
|
|
|
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
|
"""Bind Space account to existing local account"""
|
|
local_account = await self.get_user_by_email(user_email)
|
|
if local_account is None:
|
|
raise ValueError('User not found')
|
|
# Exchange code for tokens
|
|
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
|
access_token = token_data.get('access_token')
|
|
refresh_token = token_data.get('refresh_token')
|
|
expires_in = token_data.get('expires_in', 0)
|
|
|
|
if not access_token:
|
|
raise ValueError('Failed to get access token from Space')
|
|
|
|
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
|
|
|
|
# Get Space user info (token just obtained, use raw API)
|
|
user_info = await self.ap.space_service.get_user_info_raw(access_token)
|
|
account = user_info.get('account', {})
|
|
api_key = user_info.get('api_key', '')
|
|
|
|
space_account_uuid = account.get('uuid')
|
|
space_email = account.get('email')
|
|
|
|
if not space_account_uuid or not space_email:
|
|
raise ValueError('Invalid Space user info')
|
|
|
|
# Check if this Space account is already bound to another user
|
|
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
|
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
|
|
raise ValueError('This Space account is already bound to another user')
|
|
|
|
# Update local account to Space account
|
|
normalized_email = normalize_email(user_email)
|
|
await self._identity_execute(
|
|
sqlalchemy.update(user.User)
|
|
.where(user.User.normalized_email == normalized_email)
|
|
.values(
|
|
user=normalize_email(space_email), # Update email to Space email
|
|
normalized_email=normalize_email(space_email),
|
|
account_type='space',
|
|
space_account_uuid=space_account_uuid,
|
|
space_access_token=access_token,
|
|
space_refresh_token=refresh_token,
|
|
space_api_key=api_key,
|
|
space_access_token_expires_at=expires_at,
|
|
),
|
|
f'email:{normalized_email}',
|
|
)
|
|
|
|
# Update Space model provider API keys
|
|
await self._update_space_provider_for_account(local_account, api_key)
|
|
|
|
return await self.get_user_by_email(space_email)
|