feat(auth): add admin-managed bot API keys with expiry and revocation
This commit is contained in:
+141
-2
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import app.api.users as users_api
|
||||
from app.core.security import hash_password, hash_token
|
||||
from app.domain.models import AuthToken, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, BotPrincipal, TokenPrincipal, User
|
||||
from app.main import app
|
||||
from app.core.auth import get_auth_repository
|
||||
from app.services.auth_service import AuthService, get_auth_service
|
||||
@@ -19,8 +19,10 @@ class FakeUserRepo:
|
||||
self.users_by_username: dict[str, User] = {}
|
||||
self.tokens: dict[str, AuthToken] = {}
|
||||
self.token_principals: dict[str, TokenPrincipal] = {}
|
||||
self.bots: dict[str, BotPrincipal] = {}
|
||||
self.next_id = 1
|
||||
self.next_token_id = 1
|
||||
self.next_bot_id = 1
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
user = self.users.get(user_id)
|
||||
@@ -112,6 +114,7 @@ class FakeUserRepo:
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
bot_principal_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> AuthToken:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -122,6 +125,7 @@ class FakeUserRepo:
|
||||
token_prefix=token_prefix,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
bot_principal_id=bot_principal_id,
|
||||
expires_at=expires_at,
|
||||
created_at=now,
|
||||
)
|
||||
@@ -132,6 +136,7 @@ class FakeUserRepo:
|
||||
user_id=user_id,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
bot_principal_id=bot_principal_id,
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -151,11 +156,73 @@ class FakeUserRepo:
|
||||
token = self.tokens.get(principal.token_id)
|
||||
if token is None or token.revoked_at is not None:
|
||||
return None
|
||||
if token.bot_principal_id is not None:
|
||||
bot = self.bots.get(token.bot_principal_id)
|
||||
if bot is None or not bot.is_active:
|
||||
return None
|
||||
if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc):
|
||||
return None
|
||||
token.last_used_at = datetime.now(timezone.utc)
|
||||
return principal
|
||||
|
||||
def create_bot_principal(
|
||||
self,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
) -> BotPrincipal:
|
||||
self.get_user(owner_user_id)
|
||||
if any(item.name == name for item in self.bots.values()):
|
||||
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
|
||||
now = datetime.now(timezone.utc)
|
||||
bot = BotPrincipal(
|
||||
id=f"bot-{self.next_bot_id}",
|
||||
owner_user_id=owner_user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.next_bot_id += 1
|
||||
self.bots[bot.id] = bot
|
||||
return bot
|
||||
|
||||
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
|
||||
bot = self.bots.get(bot_principal_id)
|
||||
if bot is None:
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
return bot
|
||||
|
||||
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
|
||||
items = list(self.bots.values())
|
||||
if is_active is not None:
|
||||
items = [item for item in items if item.is_active == is_active]
|
||||
return sorted(items, key=lambda item: item.name)
|
||||
|
||||
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
|
||||
bot = self.get_bot_principal(bot_principal_id)
|
||||
bot.is_active = False
|
||||
now = datetime.now(timezone.utc)
|
||||
bot.deactivated_at = now
|
||||
bot.updated_at = now
|
||||
for token in self.tokens.values():
|
||||
if token.bot_principal_id == bot_principal_id and token.revoked_at is None:
|
||||
token.revoked_at = now
|
||||
return bot
|
||||
|
||||
def list_bot_auth_tokens(self, bot_principal_id: str) -> list[AuthToken]:
|
||||
self.get_bot_principal(bot_principal_id)
|
||||
return [token for token in self.tokens.values() if token.bot_principal_id == bot_principal_id]
|
||||
|
||||
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
|
||||
self.get_bot_principal(bot_principal_id)
|
||||
token = self.tokens.get(token_id)
|
||||
if token is None or token.bot_principal_id != bot_principal_id:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
@@ -259,4 +326,76 @@ def test_login_and_issue_client_token_flow(monkeypatch) -> None:
|
||||
token_payload = token_response.json()
|
||||
assert token_payload["auth_token"]["name"] == "mobile-app"
|
||||
assert token_payload["auth_token"]["token_type"] == "client"
|
||||
assert token_payload["token"]
|
||||
assert token_payload["token"]
|
||||
|
||||
|
||||
def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
owner = repo.create_user("bot-owner", password_hash=hash_password("password123"), permission="trade-write")
|
||||
admin = repo.create_user("admin", password_hash=hash_password("admin123!"), permission="admin")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "admin", "password": "admin123!"})
|
||||
assert login_response.status_code == 200
|
||||
admin_token = login_response.json()["access_token"]
|
||||
|
||||
bot_response = client.post(
|
||||
"/auth/bots",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"owner_user_id": owner.id, "name": "market-bot", "description": "external execution bot"},
|
||||
)
|
||||
assert bot_response.status_code == 201
|
||||
bot_id = bot_response.json()["id"]
|
||||
|
||||
issue_response = client.post(
|
||||
f"/auth/bots/{bot_id}/tokens",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"name": "prod-key", "permission": "read", "expires_in_days": 30},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert issue_response.status_code == 201
|
||||
payload = issue_response.json()
|
||||
assert payload["token"]
|
||||
assert payload["auth_token"]["token_type"] == "bot"
|
||||
assert payload["auth_token"]["bot_principal_id"] == bot_id
|
||||
assert payload["auth_token"]["expires_at"] is not None
|
||||
|
||||
|
||||
def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
owner = repo.create_user("bot-owner", password_hash=hash_password("password123"), permission="read")
|
||||
admin = repo.create_user("admin", password_hash=hash_password("admin123!"), permission="admin")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "admin", "password": "admin123!"})
|
||||
assert login_response.status_code == 200
|
||||
admin_token = login_response.json()["access_token"]
|
||||
|
||||
bot_response = client.post(
|
||||
"/auth/bots",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"owner_user_id": owner.id, "name": "ops-bot"},
|
||||
)
|
||||
assert bot_response.status_code == 201
|
||||
bot_id = bot_response.json()["id"]
|
||||
|
||||
issue_response = client.post(
|
||||
f"/auth/bots/{bot_id}/tokens",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"name": "ops-key", "permission": "read"},
|
||||
)
|
||||
assert issue_response.status_code == 201
|
||||
bot_token = issue_response.json()["token"]
|
||||
|
||||
deactivate_response = client.delete(
|
||||
f"/auth/bots/{bot_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert deactivate_response.status_code == 200
|
||||
|
||||
denied_response = client.get("/auth/tokens", headers={"Authorization": f"Bearer {bot_token}"})
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert denied_response.status_code == 401
|
||||
assert denied_response.json()["detail"] == "Invalid token"
|
||||
Reference in New Issue
Block a user