mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
Let users sign in with their directory (Active Directory / OpenLDAP) credentials through the existing login form. Reuses sso_providers (kind='ldap') and user_sso_identities, so no schema change is needed. Authentication is search-then-bind: a service account resolves the user and only the returned DN is bound with the submitted password, so user input is never used as a bind identity. Roles come from a role template at first provisioning only; changing a directory group later never re-templates an existing account. Directory outages and wrong passwords stay distinguishable (502 LDAP_UNAVAILABLE vs 401 AUTH_FAILED). Hardening from review: - An account holding its own password stops at the local check, so a local secret is never forwarded to the directory nor counted against that directory's own lockout policy. - A lookup without a unique exact identifier match is refused instead of being bound against the first hit. - A failure during the user bind surfaces as LDAP_UNAVAILABLE rather than a 401 credential verdict. - Binds are throttled before they reach the directory, keyed per identifier and client address, with the address resolved from a trusted peer so a spoofed X-Forwarded-For cannot reset the budget. - Enabling a plain ldap:// URL without StartTLS is rejected; a disabled certificate check is surfaced as a warning. - auto_provision defaults to off, with an optional allowed_groups allow-list and a group-search mode (member / uniqueMember / memberUid) for directories that do not expose memberOf. Nested groups are not resolved. - The identity key is configurable (entryUUID / objectGUID) and blank by default; a connectivity probe reports which attribute the directory exposes. - Config changes are audited against the acting admin, and referrals are no longer followed during binds. Tests cover the above without a live directory (ldap3.Connection is the only thing replaced); a live test exercises a real directory when configured. Co-authored-by: jubaoliang <jubaoliang@gmail.com>
479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""tests/unit/test_user_manager.py"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from octop.config import OctopConfig
|
|
from octop.infra.db.migrate import run_migrations
|
|
from octop.infra.db.pool import SqlitePool
|
|
from octop.infra.db.services import build_shared_services
|
|
from octop.infra.errors import ErrorCode, OctopError
|
|
from octop.infra.users.identity import Role
|
|
from octop.infra.users.manager import UserManager
|
|
from octop.infra.utils.paths import PathLayout
|
|
|
|
|
|
def _insert_sso_provider(manager: UserManager, *, kind: str = "oidc") -> int:
|
|
with manager._services.db.transaction() as conn:
|
|
cursor = conn.execute(
|
|
"INSERT INTO sso_providers(enabled, kind, created_at, updated_at) VALUES (1, ?, 0, 0)",
|
|
(kind,),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
|
|
@pytest.fixture
|
|
async def manager(tmp_path: Path) -> UserManager:
|
|
paths = PathLayout(tmp_path / ".octop")
|
|
paths.ensure_root()
|
|
db = SqlitePool(paths.db)
|
|
run_migrations(db)
|
|
services = build_shared_services(db=db, paths=paths, config=OctopConfig())
|
|
return UserManager(services)
|
|
|
|
|
|
async def test_create_user_writes_db(manager: UserManager):
|
|
user = await manager.create(
|
|
username="alice", password="TestPass12", role=Role.ADMIN, display_name="Alice"
|
|
)
|
|
assert user.username == "alice"
|
|
assert user.role == Role.ADMIN
|
|
assert manager.get("alice") is not None
|
|
|
|
|
|
async def test_duplicate_username_rejected(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.create(username="a", password="TestPass34", role=Role.USER)
|
|
assert ei.value.code is ErrorCode.USERNAME_TAKEN
|
|
|
|
|
|
async def test_authenticate_success(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
user = await manager.authenticate("a", "TestPass12")
|
|
assert user is not None
|
|
assert user.username == "a"
|
|
|
|
|
|
async def test_authenticate_by_email(manager: UserManager):
|
|
await manager.create(
|
|
username="alice",
|
|
password="TestPass12",
|
|
role=Role.USER,
|
|
email="Alice@Example.com",
|
|
)
|
|
user = await manager.authenticate("alice@example.com", "TestPass12")
|
|
assert user is not None
|
|
assert user.username == "alice"
|
|
|
|
|
|
async def test_authenticate_prefers_username_over_email(manager: UserManager):
|
|
await manager.create(
|
|
username="bob@x.com",
|
|
password="TestPass12",
|
|
role=Role.USER,
|
|
email="other@x.com",
|
|
)
|
|
await manager.create(
|
|
username="carol",
|
|
password="TestPass34",
|
|
role=Role.USER,
|
|
email="bob@x.com",
|
|
)
|
|
user = await manager.authenticate("bob@x.com", "TestPass12")
|
|
assert user is not None
|
|
assert user.username == "bob@x.com"
|
|
|
|
|
|
async def test_create_duplicate_email_rejected(manager: UserManager):
|
|
await manager.create(
|
|
username="a", password="TestPass12", role=Role.USER, email="dup@example.com"
|
|
)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.create(
|
|
username="b", password="TestPass34", role=Role.USER, email="DUP@example.com"
|
|
)
|
|
assert ei.value.code is ErrorCode.EMAIL_TAKEN
|
|
|
|
|
|
async def test_set_email_and_clear(manager: UserManager):
|
|
user = await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
await manager.set_email("a", "a@example.com")
|
|
row = manager.get_row(user.id)
|
|
assert row is not None
|
|
assert row.email == "a@example.com"
|
|
await manager.set_email("a", None)
|
|
row = manager.get_row(user.id)
|
|
assert row is not None
|
|
assert row.email is None
|
|
|
|
|
|
async def test_authenticate_wrong_password(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
assert await manager.authenticate("a", "bad") is None
|
|
|
|
|
|
async def test_authenticate_rejects_null_password_hash(manager: UserManager):
|
|
manager._services.user_repo.create(username="sso_user", password_hash=None, role="user")
|
|
assert await manager.authenticate("sso_user", "any") is None
|
|
|
|
|
|
async def test_change_password_rejects_null_password_hash(manager: UserManager):
|
|
"""Directory/SSO-provisioned accounts have no local password to change."""
|
|
manager._services.user_repo.create(username="sso_user", password_hash=None, role="user")
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.change_password("sso_user", "any", "NewPass12")
|
|
assert ei.value.code is ErrorCode.PASSWORD_NOT_SET
|
|
assert ei.value.status == 400
|
|
|
|
|
|
async def test_sso_create_then_updates_same_subject(manager: UserManager):
|
|
provider_id = _insert_sso_provider(manager)
|
|
user = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"preferred_username": "alice", "email": "Alice@Example.com", "name": "Alice"},
|
|
)
|
|
|
|
assert user.role == Role.USER
|
|
assert "channels" in user.permissions
|
|
row = manager.get_row(user.id)
|
|
assert row is not None
|
|
assert row.role_name == "用户"
|
|
assert row.role == "user"
|
|
assert row.password_hash is None
|
|
assert row.email == "alice@example.com"
|
|
assert row.display_name == "Alice"
|
|
assert row.sso_provider_id == provider_id
|
|
assert row.sso_subject == "sub-1"
|
|
|
|
updated = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"email": "alice-2@example.com", "name": "Alice 2"},
|
|
)
|
|
|
|
assert updated.id == user.id
|
|
row = manager.get_row(user.id)
|
|
assert row.display_name == "Alice"
|
|
assert row.email == "alice-2@example.com"
|
|
|
|
|
|
async def test_sso_create_recovers_when_another_worker_wins_identity_race(
|
|
manager: UserManager, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
provider_id = _insert_sso_provider(manager)
|
|
repo = manager._services.user_repo
|
|
create = repo.create
|
|
|
|
def create_after_race(*args: object, **kwargs: object) -> int:
|
|
create(*args, **kwargs)
|
|
raise sqlite3.IntegrityError(
|
|
"UNIQUE constraint failed: users.sso_provider_id, users.sso_subject"
|
|
)
|
|
|
|
monkeypatch.setattr(repo, "create", create_after_race)
|
|
|
|
user = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-race",
|
|
claims={"preferred_username": "alice", "name": "Alice"},
|
|
)
|
|
|
|
assert user.username == "alice"
|
|
assert repo.get_by_sso(provider_id, "sub-race").id == user.id
|
|
|
|
|
|
async def test_sso_username_allocation_sanitizes_and_suffixes_conflicts(manager: UserManager):
|
|
provider_id = _insert_sso_provider(manager)
|
|
first = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"preferred_username": "alice smith!"},
|
|
)
|
|
second = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-2",
|
|
claims={"preferred_username": "alice smith!"},
|
|
)
|
|
|
|
assert first.username == "alicesmith"
|
|
assert second.username == "alicesmith_2"
|
|
|
|
|
|
async def test_sso_email_conflict_does_not_overwrite_existing_email(manager: UserManager):
|
|
provider_id = _insert_sso_provider(manager)
|
|
manager._services.user_repo.create(
|
|
username="local",
|
|
password_hash="not-used",
|
|
role="user",
|
|
email="taken@example.com",
|
|
)
|
|
|
|
user = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"preferred_username": "sso-user", "email": "taken@example.com"},
|
|
)
|
|
|
|
assert manager.get_row(user.id).email is None
|
|
|
|
|
|
async def test_sso_disabled_user_is_rejected(manager: UserManager):
|
|
provider_id = _insert_sso_provider(manager)
|
|
user = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"preferred_username": "alice"},
|
|
)
|
|
await manager.disable(user.username)
|
|
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"name": "Alice"},
|
|
)
|
|
assert ei.value.code is ErrorCode.USER_DISABLED
|
|
|
|
|
|
async def test_bind_sso_identity_links_current_user_and_rejects_taken_identity(
|
|
manager: UserManager,
|
|
):
|
|
provider_id = _insert_sso_provider(manager, kind="feishu")
|
|
local = await manager.create(
|
|
username="alice", password="TestPass12", role=Role.ADMIN, display_name="Alice"
|
|
)
|
|
other = await manager.create(
|
|
username="bob", password="TestPass12", role=Role.USER, display_name="Bob"
|
|
)
|
|
await manager.bind_sso_identity(
|
|
user_id=other.id,
|
|
provider_id=provider_id,
|
|
subject="union-bob",
|
|
claims={"name": "Bob Feishu"},
|
|
)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.bind_sso_identity(
|
|
user_id=local.id,
|
|
provider_id=provider_id,
|
|
subject="union-bob",
|
|
claims={},
|
|
)
|
|
assert ei.value.code is ErrorCode.SSO_IDENTITY_TAKEN
|
|
|
|
bound = await manager.bind_sso_identity(
|
|
user_id=local.id,
|
|
provider_id=provider_id,
|
|
subject="union-alice",
|
|
claims={"name": "Alice Feishu", "email": "alice@example.com"},
|
|
)
|
|
assert bound.id == local.id
|
|
row = manager.get_row(local.id)
|
|
assert row.sso_provider_id == provider_id
|
|
assert row.sso_subject == "union-alice"
|
|
assert row.display_name == "Alice"
|
|
assert bound.display_name == "Alice"
|
|
identities = manager.list_sso_identities(local.id)
|
|
assert [(item.kind, item.subject) for item in identities] == [("feishu", "union-alice")]
|
|
|
|
|
|
async def test_bind_multiple_sso_identities_on_same_user(manager: UserManager):
|
|
oidc_id = _insert_sso_provider(manager, kind="oidc")
|
|
feishu_id = _insert_sso_provider(manager, kind="feishu")
|
|
local = await manager.create(
|
|
username="alice", password="TestPass12", role=Role.USER, display_name="Alice"
|
|
)
|
|
await manager.bind_sso_identity(
|
|
user_id=local.id, provider_id=oidc_id, subject="oidc-alice", claims={}
|
|
)
|
|
await manager.bind_sso_identity(
|
|
user_id=local.id, provider_id=feishu_id, subject="feishu-alice", claims={}
|
|
)
|
|
identities = manager.list_sso_identities(local.id)
|
|
assert {(item.kind, item.subject) for item in identities} == {
|
|
("oidc", "oidc-alice"),
|
|
("feishu", "feishu-alice"),
|
|
}
|
|
assert manager._services.user_repo.get_by_sso(oidc_id, "oidc-alice").id == local.id
|
|
assert manager._services.user_repo.get_by_sso(feishu_id, "feishu-alice").id == local.id
|
|
|
|
await manager.unbind_sso_identity(user_id=local.id, kind="feishu")
|
|
remaining = manager.list_sso_identities(local.id)
|
|
assert [(item.kind, item.subject) for item in remaining] == [("oidc", "oidc-alice")]
|
|
|
|
|
|
async def test_unbind_sso_identity_requires_password(manager: UserManager):
|
|
provider_id = _insert_sso_provider(manager, kind="feishu")
|
|
user = await manager.resolve_or_create_sso_user(
|
|
provider_id=provider_id,
|
|
subject="sub-1",
|
|
claims={"preferred_username": "ssoonly"},
|
|
)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.unbind_sso_identity(user_id=user.id, kind="feishu")
|
|
assert ei.value.code is ErrorCode.SSO_UNBIND_REQUIRES_PASSWORD
|
|
|
|
local = await manager.create(username="alice", password="TestPass12", role=Role.USER)
|
|
await manager.bind_sso_identity(
|
|
user_id=local.id, provider_id=provider_id, subject="sub-2", claims={}
|
|
)
|
|
unbound = await manager.unbind_sso_identity(user_id=local.id, kind="feishu")
|
|
assert unbound.id == local.id
|
|
row = manager.get_row(local.id)
|
|
assert row.sso_provider_id is None
|
|
assert row.sso_subject is None
|
|
assert manager.list_sso_identities(local.id) == []
|
|
|
|
|
|
async def test_authenticate_locks_after_max_failures(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
max_attempts = manager._login_max_attempts
|
|
for _ in range(max_attempts - 1):
|
|
assert await manager.authenticate("a", "bad") is None
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.authenticate("a", "bad")
|
|
assert ei.value.code is ErrorCode.LOGIN_LOCKED
|
|
with pytest.raises(OctopError) as ei2:
|
|
await manager.authenticate("a", "bad")
|
|
assert ei2.value.code is ErrorCode.LOGIN_LOCKED
|
|
with pytest.raises(OctopError) as ei3:
|
|
await manager.authenticate("a", "TestPass12")
|
|
assert ei3.value.code is ErrorCode.LOGIN_LOCKED
|
|
|
|
|
|
async def test_reset_password_clears_lockout(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
max_attempts = manager._login_max_attempts
|
|
for _ in range(max_attempts - 1):
|
|
assert await manager.authenticate("a", "bad") is None
|
|
with pytest.raises(OctopError):
|
|
await manager.authenticate("a", "bad")
|
|
with pytest.raises(OctopError):
|
|
await manager.authenticate("a", "TestPass12")
|
|
await manager.reset_password("a", "NewPass12")
|
|
user = await manager.authenticate("a", "NewPass12")
|
|
assert user is not None
|
|
|
|
|
|
async def test_authenticate_disabled_returns_none(manager: UserManager):
|
|
user = await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
await manager.disable(user.username)
|
|
assert await manager.authenticate("a", "TestPass12") is None
|
|
|
|
|
|
async def test_get_by_username_and_id(manager: UserManager):
|
|
user = await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
assert manager.get("a").id == user.id
|
|
assert manager.get_by_id(user.id).username == "a"
|
|
|
|
|
|
async def test_change_password(manager: UserManager):
|
|
user = await manager.create(username="a", password="OldPass12", role=Role.USER)
|
|
await manager.change_password(user.username, "OldPass12", "NewPass12")
|
|
assert await manager.authenticate("a", "NewPass12") is not None
|
|
assert await manager.authenticate("a", "OldPass12") is None
|
|
|
|
|
|
async def test_change_password_wrong_old(manager: UserManager):
|
|
await manager.create(username="a", password="OldPass12", role=Role.USER)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.change_password("a", "wrong", "NewPass12")
|
|
assert ei.value.code is ErrorCode.AUTH_FAILED
|
|
|
|
|
|
async def test_change_password_rejects_same_as_old(manager: UserManager):
|
|
await manager.create(username="a", password="OldPass12", role=Role.USER)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.change_password("a", "OldPass12", "OldPass12")
|
|
assert ei.value.code is ErrorCode.PASSWORD_SAME_AS_OLD
|
|
|
|
|
|
async def test_change_password_rejects_weak_new(manager: UserManager):
|
|
await manager.create(username="a", password="OldPass12", role=Role.USER)
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.change_password("a", "OldPass12", "12345678")
|
|
assert ei.value.code is ErrorCode.PASSWORD_TOO_WEAK
|
|
|
|
|
|
async def test_create_rejects_weak_password(manager: UserManager):
|
|
with pytest.raises(OctopError) as ei:
|
|
await manager.create(username="a", password="pw", role=Role.USER)
|
|
assert ei.value.code is ErrorCode.PASSWORD_TOO_WEAK
|
|
|
|
|
|
async def test_set_role(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
await manager.set_role("a", Role.ADMIN)
|
|
assert manager.get("a").role == Role.ADMIN
|
|
|
|
|
|
async def test_disable_removes_from_memory(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
await manager.disable("a")
|
|
assert manager.get("a") is None
|
|
|
|
|
|
async def test_enable_restores_memory(manager: UserManager):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
await manager.disable("a")
|
|
await manager.enable("a")
|
|
assert manager.get("a") is not None
|
|
|
|
|
|
async def test_remove_deletes_db_and_dir(manager: UserManager, tmp_path: Path):
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
await manager.remove("a")
|
|
assert (tmp_path / ".octop" / "users" / "a").exists() is False
|
|
assert manager.get("a") is None
|
|
|
|
|
|
async def test_count(manager: UserManager):
|
|
assert manager.count() == 0
|
|
await manager.create(username="a", password="TestPass12", role=Role.USER)
|
|
assert manager.count() == 1
|
|
|
|
|
|
async def test_boot_loads_existing_users(tmp_path: Path):
|
|
paths = PathLayout(tmp_path / ".octop")
|
|
paths.ensure_root()
|
|
db = SqlitePool(paths.db)
|
|
run_migrations(db)
|
|
services = build_shared_services(db=db, paths=paths, config=OctopConfig())
|
|
# Pre-populate via repo
|
|
from octop.infra.users.password import hash_password
|
|
|
|
services.user_repo.create(username="legacy", password_hash=hash_password("pw"), role="user")
|
|
manager = UserManager(services)
|
|
await manager.boot()
|
|
assert manager.get("legacy") is not None
|
|
|
|
|
|
async def test_boot_loads_users(tmp_path: Path):
|
|
"""UserManager.boot() loads user identity objects (agents are now global)."""
|
|
paths = PathLayout(tmp_path / ".octop")
|
|
paths.ensure_root()
|
|
db = SqlitePool(paths.db)
|
|
run_migrations(db)
|
|
services = build_shared_services(db=db, paths=paths, config=OctopConfig())
|
|
from octop.infra.users.password import hash_password
|
|
|
|
uid = services.user_repo.create(
|
|
username="alice", password_hash=hash_password("pw"), role="user"
|
|
)
|
|
manager = UserManager(services)
|
|
await manager.boot()
|
|
try:
|
|
user = manager.get("alice")
|
|
assert user is not None
|
|
assert user.id == uid
|
|
assert user.username == "alice"
|
|
# No agent_manager on User anymore — agents are managed globally
|
|
assert not hasattr(user, "agent_manager") or True # just verify no crash
|
|
finally:
|
|
await manager.shutdown_all()
|