mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 06:00:38 +00:00
* fix(auth): claim the first admin atomically instead of counting first POST /api/v1/auth/initialize read the admin count in one session and created the admin in another, with no lock between them. Two requests arriving together with different emails both saw an empty system and both became admins: reproduced over HTTP, two 201s and two admin rows, after which setup-status reports an initialized system. The endpoint is public and unthrottled, and during the pre-setup window anyone who can reach the gateway can claim admin with a single request anyway — that part is the first-boot design. What the race removes is the only signal: without it exactly one request wins and the loser gets 409, so an operator who loses sees "already initialized" and looks into it. With it, both succeed and the operator's setup looks ordinary. The check and the insert now share one transaction in the repository, with writers serialized first — SQLite takes the write lock up front (BEGIN IMMEDIATE) and Postgres a transaction-scoped advisory lock, the idiom the project and channel repositories already use. The same repository serves both backends, and multi-replica deployments make the race genuinely parallel rather than an event-loop interleaving. create_first_admin returns None when the claim is lost, which the route reports as 409; uniqueness conflicts still raise ValueError and stay a 400. The count in the handler is now only a fast path that avoids hashing a password for an already-initialized system. AUTH_DESIGN.md said concurrent initialization was backstopped by a database unique constraint. No such constraint exists — email uniqueness only catches the same address submitted twice — so it now describes the claim that does the work. * docs(changelog): note the atomic first-admin claim (#5776) * fix(auth): refuse a dialect the first-admin claim cannot serialize Review follow-up. The claim's serialization handled sqlite and postgresql and fell through silently for anything else, which would leave create_first_admin as the plain check-then-act it exists to prevent — the one place the abstract method's atomicity contract is enforced. An unknown dialect now raises instead. The engine builds only these two today, so this guards a future backend: it fails loudly at first boot rather than quietly reopening the race.
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
"""Local email/password authentication provider."""
|
|
|
|
import logging
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.auth.password import hash_password_async, needs_rehash, verify_password_async
|
|
from app.gateway.auth.providers import AuthProvider
|
|
from app.gateway.auth.repositories.base import UserRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LocalAuthProvider(AuthProvider):
|
|
"""Email/password authentication provider using local database."""
|
|
|
|
def __init__(self, repository: UserRepository):
|
|
"""Initialize with a UserRepository.
|
|
|
|
Args:
|
|
repository: UserRepository implementation (SQLite)
|
|
"""
|
|
self._repo = repository
|
|
|
|
async def authenticate(self, credentials: dict) -> User | None:
|
|
"""Authenticate with email and password.
|
|
|
|
Args:
|
|
credentials: dict with 'email' and 'password' keys
|
|
|
|
Returns:
|
|
User if authentication succeeds, None otherwise
|
|
"""
|
|
email = credentials.get("email")
|
|
password = credentials.get("password")
|
|
|
|
if not email or not password:
|
|
return None
|
|
|
|
user = await self._repo.get_user_by_email(email)
|
|
if user is None:
|
|
return None
|
|
|
|
if user.password_hash is None:
|
|
# OAuth user without local password
|
|
return None
|
|
|
|
if not await verify_password_async(password, user.password_hash):
|
|
return None
|
|
|
|
if needs_rehash(user.password_hash):
|
|
try:
|
|
user.password_hash = await hash_password_async(password)
|
|
await self._repo.update_user(user)
|
|
except Exception:
|
|
# Rehash is an opportunistic upgrade; a transient DB error must not
|
|
# prevent an otherwise-valid login from succeeding.
|
|
logger.warning("Failed to rehash password for user %s; login will still succeed", user.email, exc_info=True)
|
|
|
|
return user
|
|
|
|
async def get_user(self, user_id: str) -> User | None:
|
|
"""Get user by ID."""
|
|
return await self._repo.get_user_by_id(user_id)
|
|
|
|
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
|
|
"""Create a new local user.
|
|
|
|
Args:
|
|
email: User email address
|
|
password: Plain text password (will be hashed)
|
|
system_role: Role to assign ("admin" or "user")
|
|
needs_setup: If True, user must complete setup on first login
|
|
|
|
Returns:
|
|
Created User instance
|
|
"""
|
|
password_hash = await hash_password_async(password) if password else None
|
|
user = User(
|
|
email=email,
|
|
password_hash=password_hash,
|
|
system_role=system_role,
|
|
needs_setup=needs_setup,
|
|
)
|
|
return await self._repo.create_user(user)
|
|
|
|
async def create_first_admin(self, email: str, password: str) -> User | None:
|
|
"""Create the first admin account, or return None if one already exists.
|
|
|
|
The check and the insert are one atomic claim in the repository, so
|
|
concurrent first-boot requests cannot both succeed.
|
|
|
|
Args:
|
|
email: Admin email address
|
|
password: Plain text password (will be hashed)
|
|
|
|
Returns:
|
|
Created admin User, or None if the system already has an admin
|
|
"""
|
|
password_hash = await hash_password_async(password)
|
|
user = User(email=email, password_hash=password_hash, system_role="admin", needs_setup=False)
|
|
return await self._repo.create_first_admin(user)
|
|
|
|
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
|
|
"""Get user by OAuth provider and ID."""
|
|
return await self._repo.get_user_by_oauth(provider, oauth_id)
|
|
|
|
async def count_users(self) -> int:
|
|
"""Return total number of registered users."""
|
|
return await self._repo.count_users()
|
|
|
|
async def count_admin_users(self) -> int:
|
|
"""Return number of admin users."""
|
|
return await self._repo.count_admin_users()
|
|
|
|
async def update_user(self, user: User) -> User:
|
|
"""Update an existing user."""
|
|
return await self._repo.update_user(user)
|
|
|
|
async def get_user_by_email(self, email: str) -> User | None:
|
|
"""Get user by email."""
|
|
return await self._repo.get_user_by_email(email)
|
|
|
|
async def create_oauth_user(
|
|
self,
|
|
email: str,
|
|
oauth_provider: str,
|
|
oauth_id: str,
|
|
system_role: str = "user",
|
|
) -> User:
|
|
"""Create a new user from an OAuth/OIDC login.
|
|
|
|
Args:
|
|
email: Verified email from the OIDC provider
|
|
oauth_provider: Provider ID (e.g. 'keycloak', 'google')
|
|
oauth_id: User's subject claim from the ID token
|
|
system_role: Role to assign ("admin" or "user")
|
|
|
|
Returns:
|
|
Created User instance
|
|
"""
|
|
user = User(
|
|
email=email,
|
|
password_hash=None,
|
|
system_role=system_role,
|
|
needs_setup=False,
|
|
oauth_provider=oauth_provider,
|
|
oauth_id=oauth_id,
|
|
)
|
|
return await self._repo.create_user(user)
|