252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""Local authentication provider — SQLite + bcrypt.
|
|
|
|
The default :class:`AuthProvider` implementation. Authenticates users
|
|
against the local ``users`` table in the auth SQLite database using
|
|
the bcrypt cost=12 password hash (see :mod:`agentkit.server.auth.password`).
|
|
|
|
This is a behavioral equivalent of the password-verification code that
|
|
previously lived inline in ``routes/auth.py`` — moved here so the route
|
|
layer can call a single :meth:`authenticate` method regardless of which
|
|
backend is configured.
|
|
|
|
Future-IdP note
|
|
---------------
|
|
When the organization moves to OIDC / SAML / LDAP, this class does not
|
|
need to be deleted. It can continue to serve as a "local emergency
|
|
account" provider, configurable side-by-side with the IdP provider via
|
|
a future composite / multi-provider setup. For now it is the only
|
|
implementation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import aiosqlite
|
|
|
|
from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
|
|
from ..password import hash_password, verify_password
|
|
from .user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _resolve_db_path() -> Path:
|
|
"""Resolve the auth DB path with runtime env-var priority.
|
|
|
|
The :data:`models.DEFAULT_AUTH_DB_PATH` constant is captured at
|
|
module-import time and therefore cannot see test-time env mutations.
|
|
Re-reading ``AGENTKIT_AUTH_DB`` here keeps the provider
|
|
"test-friendly" (tests can ``monkeypatch.setenv`` before constructing
|
|
the provider) without giving up the default path when no env is set.
|
|
"""
|
|
env = os.environ.get("AGENTKIT_AUTH_DB")
|
|
if env:
|
|
return Path(env)
|
|
return DEFAULT_AUTH_DB_PATH
|
|
|
|
|
|
class LocalAuthProvider:
|
|
"""AuthProvider backed by the local SQLite ``users`` table + bcrypt.
|
|
|
|
Args:
|
|
db_path: Path to the auth DB. Defaults to the value of the
|
|
``AGENTKIT_AUTH_DB`` env var, falling back to
|
|
:data:`agentkit.server.auth.models.DEFAULT_AUTH_DB_PATH`.
|
|
Each operation opens a short-lived aiosqlite connection;
|
|
the existing route layer follows the same pattern, so no
|
|
connection pooling is introduced here. If a future
|
|
deployment needs pooling, swap in a ``db_factory: Callable``
|
|
here without changing the protocol.
|
|
"""
|
|
|
|
name = "local"
|
|
|
|
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
self._db_path = Path(db_path) if db_path is not None else _resolve_db_path()
|
|
|
|
@property
|
|
def db_path(self) -> Path:
|
|
return self._db_path
|
|
|
|
async def authenticate(self, *, username: str, password: str) -> User:
|
|
"""Verify the username + password against the local users table.
|
|
|
|
Raises :class:`InvalidCredentials` on every failure mode
|
|
(unknown user, wrong password, inactive user) with the same
|
|
error message — preventing username enumeration via error
|
|
inspection. Constant-time-equivalent behavior is also
|
|
ensured by always running a real bcrypt computation
|
|
(against a dummy hash) when the user does not exist,
|
|
matching the timing of the "user exists, wrong password" path.
|
|
"""
|
|
from .exceptions import InvalidCredentials # local import to avoid cycle at module load
|
|
|
|
async with aiosqlite.connect(str(self._db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"SELECT id, username, email, password_hash, role, is_active, "
|
|
"is_terminal_authorized, is_server_terminal_authorized, "
|
|
"created_at, updated_at, last_login_at, created_by "
|
|
"FROM users WHERE username = ?",
|
|
(username,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
|
|
if row is None or not bool(row["is_active"]):
|
|
# Run a real bcrypt verification against a valid-format dummy
|
|
# hash so the response time matches the "user exists, wrong
|
|
# password" path (~250ms). Prevents username enumeration via
|
|
# timing. The dummy hash is invalid (won't match any password)
|
|
# but has the right shape so bcrypt.checkpw doesn't short-circuit.
|
|
_DUMMY_BCRYPT_HASH = "$2b$12$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ0123"
|
|
verify_password(password, _DUMMY_BCRYPT_HASH)
|
|
raise InvalidCredentials("invalid username or password")
|
|
|
|
if not verify_password(password, row["password_hash"]):
|
|
raise InvalidCredentials("invalid username or password")
|
|
|
|
return _row_to_user(row)
|
|
|
|
async def get_user_by_id(self, user_id: str) -> User | None:
|
|
"""Look up a user by id. Returns ``None`` if not found or inactive."""
|
|
async with aiosqlite.connect(str(self._db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"SELECT id, username, email, password_hash, role, is_active, "
|
|
"is_terminal_authorized, is_server_terminal_authorized, "
|
|
"created_at, updated_at, last_login_at, created_by "
|
|
"FROM users WHERE id = ? AND is_active = 1",
|
|
(user_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
return _row_to_user(row) if row else None
|
|
|
|
async def sync_user_attributes(self, user_id: str) -> None:
|
|
"""No-op: local provider has no upstream source of truth to sync from."""
|
|
return None
|
|
|
|
async def revoke_user(self, user_id: str) -> None:
|
|
"""Disable a user account (``is_active = 0``)."""
|
|
async with aiosqlite.connect(str(self._db_path)) as db:
|
|
await db.execute(
|
|
"UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?",
|
|
(_now_iso(), user_id),
|
|
)
|
|
await db.commit()
|
|
logger.info(f"Revoked user {user_id} via LocalAuthProvider")
|
|
|
|
async def create_user(
|
|
self,
|
|
username: str,
|
|
email: str,
|
|
password: str,
|
|
role: str = "member",
|
|
is_terminal_authorized: bool = False,
|
|
is_server_terminal_authorized: bool = False,
|
|
created_by: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Create a new user in the local ``users`` table.
|
|
|
|
Args:
|
|
username: Unique username.
|
|
email: Unique email address.
|
|
password: Plain-text password (will be bcrypt-hashed with
|
|
cost factor 12 before storage).
|
|
role: Role name (``member`` / ``operator`` / ``admin``).
|
|
Defaults to ``member``.
|
|
is_terminal_authorized: Whether the user may use the local
|
|
terminal. Defaults to ``False``.
|
|
is_server_terminal_authorized: Whether the user may use the
|
|
server terminal. Defaults to ``False``.
|
|
created_by: Optional user id of the admin who created this
|
|
user (audit trail).
|
|
|
|
Returns:
|
|
The newly-created user as a dict (via
|
|
:func:`agentkit.server.auth.models.user_row_to_dict`).
|
|
|
|
Raises:
|
|
ValueError: If a user with the same username or email
|
|
already exists (catches SQLite ``IntegrityError`` on the
|
|
``username`` / ``email`` UNIQUE constraints).
|
|
"""
|
|
user_id = str(uuid.uuid4())
|
|
password_hash = hash_password(password)
|
|
now = _now_iso()
|
|
|
|
try:
|
|
async with aiosqlite.connect(str(self._db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
await db.execute(
|
|
"INSERT INTO users "
|
|
"(id, username, email, password_hash, role, is_active, "
|
|
" is_terminal_authorized, is_server_terminal_authorized, "
|
|
" created_at, updated_at, created_by) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
user_id,
|
|
username,
|
|
email,
|
|
password_hash,
|
|
role,
|
|
1,
|
|
1 if is_terminal_authorized else 0,
|
|
1 if is_server_terminal_authorized else 0,
|
|
now,
|
|
now,
|
|
created_by,
|
|
),
|
|
)
|
|
await db.commit()
|
|
cursor = await db.execute(
|
|
"SELECT id, username, email, password_hash, role, is_active, "
|
|
"is_terminal_authorized, is_server_terminal_authorized, "
|
|
"created_at, updated_at, last_login_at, created_by "
|
|
"FROM users WHERE id = ?",
|
|
(user_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
except aiosqlite.IntegrityError as exc:
|
|
# SQLite IntegrityError message includes the column name; we
|
|
# inspect it to give the caller a useful error. If for some
|
|
# reason the message is unparseable, fall back to a generic
|
|
# "duplicate" message.
|
|
msg = str(exc).lower()
|
|
if "username" in msg:
|
|
raise ValueError(f"User with username {username!r} already exists") from exc
|
|
if "email" in msg:
|
|
raise ValueError(f"User with email {email!r} already exists") from exc
|
|
raise ValueError(f"User already exists: {exc}") from exc
|
|
|
|
assert row is not None # we just inserted it
|
|
logger.info(f"Created user {username!r} (id={user_id}) via LocalAuthProvider")
|
|
return user_row_to_dict(row)
|
|
|
|
|
|
def _row_to_user(row: aiosqlite.Row) -> User:
|
|
"""Convert a ``users`` row to a :class:`User` value object."""
|
|
return User(
|
|
id=row["id"],
|
|
username=row["username"],
|
|
email=row["email"],
|
|
role=row["role"],
|
|
is_active=bool(row["is_active"]),
|
|
is_terminal_authorized=bool(row["is_terminal_authorized"]),
|
|
is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
last_login_at=row["last_login_at"],
|
|
created_by=row["created_by"],
|
|
)
|
|
|
|
|
|
def _now_iso() -> str:
|
|
"""Return current UTC time as ISO 8601 string."""
|
|
from datetime import datetime, timezone
|
|
|
|
return datetime.now(timezone.utc).isoformat()
|