feat(auth): add DB-backed login, token management, and idempotent auth migration
This commit is contained in:
+80
-1
@@ -1,7 +1,9 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.config import get_settings
|
||||
from app.main import app
|
||||
from app.domain.models import User
|
||||
from app.main import app, create_app
|
||||
|
||||
|
||||
def test_healthcheck() -> None:
|
||||
@@ -25,3 +27,80 @@ def test_settings_default_origin() -> None:
|
||||
settings = get_settings()
|
||||
|
||||
assert settings.allowed_origins == ["http://localhost:3000"]
|
||||
|
||||
|
||||
class _BootstrapRepo:
|
||||
def __init__(self, users: list[User] | None = None) -> None:
|
||||
self.users = list(users or [])
|
||||
self.created: list[tuple[str, str | None, str | None, str]] = []
|
||||
self.closed = False
|
||||
|
||||
def list_users(self) -> list[User]:
|
||||
return list(self.users)
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
*,
|
||||
email: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
permission: str = "read",
|
||||
is_active: bool = True,
|
||||
) -> User:
|
||||
self.created.append((username, email, password_hash, permission))
|
||||
user = User(
|
||||
id=f"user-{len(self.users) + 1}",
|
||||
username=username,
|
||||
email=email,
|
||||
permission=permission,
|
||||
is_active=is_active,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
self.users.append(user)
|
||||
return user
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_startup_bootstraps_initial_user_when_users_table_empty() -> None:
|
||||
repo = _BootstrapRepo()
|
||||
settings = Settings(
|
||||
db_enabled=True,
|
||||
bootstrap_username="admin",
|
||||
bootstrap_email="admin@example.com",
|
||||
bootstrap_password="bootstrap123",
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
)
|
||||
bootstrap_app = create_app(settings_factory=lambda: settings, repository_factory=lambda current: repo)
|
||||
|
||||
with TestClient(bootstrap_app) as client:
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert repo.created[0][0] == "admin"
|
||||
assert repo.created[0][1] == "admin@example.com"
|
||||
assert repo.created[0][2] is not None
|
||||
assert repo.created[0][3] == "admin"
|
||||
assert repo.closed is True
|
||||
|
||||
|
||||
def test_startup_does_not_bootstrap_when_users_exist() -> None:
|
||||
repo = _BootstrapRepo(users=[User(id="user-1", username="existing")])
|
||||
settings = Settings(
|
||||
db_enabled=True,
|
||||
bootstrap_username="admin",
|
||||
bootstrap_email="admin@example.com",
|
||||
bootstrap_password="bootstrap123",
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
)
|
||||
bootstrap_app = create_app(settings_factory=lambda: settings, repository_factory=lambda current: repo)
|
||||
|
||||
with TestClient(bootstrap_app) as client:
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert repo.created == []
|
||||
assert repo.closed is True
|
||||
|
||||
+41
-3
@@ -39,7 +39,15 @@ class FakeRepo:
|
||||
def list_users(self) -> list[User]:
|
||||
return sorted(self._users.values(), key=lambda user: user.username)
|
||||
|
||||
def create_user(self, username: str, *, email: str | None = None) -> User:
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
*,
|
||||
email: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
permission: str = "read",
|
||||
is_active: bool = True,
|
||||
) -> User:
|
||||
if any(user.username == username for user in self._users.values()):
|
||||
raise ValueError(f"이미 존재하는 username입니다: {username}")
|
||||
if email is not None and any(user.email == email for user in self._users.values()):
|
||||
@@ -49,6 +57,9 @@ class FakeRepo:
|
||||
id=f"user-{self._user_seq}",
|
||||
username=username,
|
||||
email=email,
|
||||
permission=permission,
|
||||
is_active=is_active,
|
||||
password_hash=password_hash,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -56,7 +67,16 @@ class FakeRepo:
|
||||
self._users[user.id] = user
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User:
|
||||
def update_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
username: str | None = None,
|
||||
email: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
permission: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> User:
|
||||
user = self.get_user(user_id)
|
||||
if username is not None and any(item.id != user_id and item.username == username for item in self._users.values()):
|
||||
raise ValueError(f"이미 존재하는 username입니다: {username}")
|
||||
@@ -66,6 +86,12 @@ class FakeRepo:
|
||||
user.username = username
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if password_hash is not None:
|
||||
user.password_hash = password_hash
|
||||
if permission is not None:
|
||||
user.permission = permission
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
return user
|
||||
|
||||
@@ -459,7 +485,19 @@ def test_cli_user_create_supports_json() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "user", "create", "--username", "alice", "--email", "alice@example.com", "--force"],
|
||||
[
|
||||
"--format",
|
||||
"json",
|
||||
"user",
|
||||
"create",
|
||||
"--username",
|
||||
"alice",
|
||||
"--password",
|
||||
"password123",
|
||||
"--email",
|
||||
"alice@example.com",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
+140
-10
@@ -4,15 +4,23 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.domain.models import User
|
||||
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.main import app
|
||||
from app.core.auth import get_auth_repository
|
||||
from app.services.auth_service import AuthService, get_auth_service
|
||||
from app.services.user_service import UserService, get_user_service
|
||||
|
||||
|
||||
class FakeUserRepo:
|
||||
def __init__(self) -> None:
|
||||
self.users: dict[str, User] = {}
|
||||
self.users_by_username: dict[str, User] = {}
|
||||
self.tokens: dict[str, AuthToken] = {}
|
||||
self.token_principals: dict[str, TokenPrincipal] = {}
|
||||
self.next_id = 1
|
||||
self.next_token_id = 1
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
user = self.users.get(user_id)
|
||||
@@ -20,10 +28,21 @@ class FakeUserRepo:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
return user
|
||||
|
||||
def get_user_by_username(self, username: str) -> User | None:
|
||||
return self.users_by_username.get(username)
|
||||
|
||||
def list_users(self) -> list[User]:
|
||||
return sorted(self.users.values(), key=lambda user: user.username)
|
||||
|
||||
def create_user(self, username: str, *, email: str | None = None) -> User:
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
*,
|
||||
email: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
permission: str = "read",
|
||||
is_active: bool = True,
|
||||
) -> User:
|
||||
if any(user.username == username for user in self.users.values()):
|
||||
raise ValueError(f"이미 존재하는 username입니다: {username}")
|
||||
if email is not None and any(user.email == email for user in self.users.values()):
|
||||
@@ -33,34 +52,113 @@ class FakeUserRepo:
|
||||
id=f"user-{self.next_id}",
|
||||
username=username,
|
||||
email=email,
|
||||
permission=permission,
|
||||
is_active=is_active,
|
||||
password_hash=password_hash,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.next_id += 1
|
||||
self.users[user.id] = user
|
||||
self.users_by_username[user.username] = user
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User:
|
||||
def update_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
username: str | None = None,
|
||||
email: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
permission: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> User:
|
||||
user = self.get_user(user_id)
|
||||
if username is not None and any(item.id != user_id and item.username == username for item in self.users.values()):
|
||||
raise ValueError(f"이미 존재하는 username입니다: {username}")
|
||||
if email is not None and any(item.id != user_id and item.email == email for item in self.users.values()):
|
||||
raise ValueError(f"이미 존재하는 email입니다: {email}")
|
||||
self.users_by_username.pop(user.username, None)
|
||||
if username is not None:
|
||||
user.username = username
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if password_hash is not None:
|
||||
user.password_hash = password_hash
|
||||
if permission is not None:
|
||||
user.permission = permission
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
self.users_by_username[user.username] = user
|
||||
return user
|
||||
|
||||
def delete_user(self, user_id: str) -> None:
|
||||
if user_id not in self.users:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
del self.users[user_id]
|
||||
user = self.users.pop(user_id)
|
||||
self.users_by_username.pop(user.username, None)
|
||||
|
||||
def update_user_last_login(self, user_id: str) -> None:
|
||||
user = self.get_user(user_id)
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> AuthToken:
|
||||
now = datetime.now(timezone.utc)
|
||||
token = AuthToken(
|
||||
id=f"token-{self.next_token_id}",
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
token_prefix=token_prefix,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
expires_at=expires_at,
|
||||
created_at=now,
|
||||
)
|
||||
self.next_token_id += 1
|
||||
self.tokens[token.id] = token
|
||||
self.token_principals[token_hash] = TokenPrincipal(
|
||||
token_id=token.id,
|
||||
user_id=user_id,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
)
|
||||
return token
|
||||
|
||||
def list_auth_tokens(self, user_id: str) -> list[AuthToken]:
|
||||
return [token for token in self.tokens.values() if token.user_id == user_id]
|
||||
|
||||
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
|
||||
token = self.tokens.get(token_id)
|
||||
if token is None or token.user_id != user_id:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
def get_token_principal(self, token_hash_value: str) -> TokenPrincipal | None:
|
||||
principal = self.token_principals.get(token_hash_value)
|
||||
if principal is None:
|
||||
return None
|
||||
token = self.tokens.get(principal.token_id)
|
||||
if token is None or token.revoked_at is not None:
|
||||
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 _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
|
||||
from app.core.config import get_settings
|
||||
@@ -68,6 +166,9 @@ def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app.dependency_overrides.clear()
|
||||
app.dependency_overrides[get_user_service] = lambda: UserService(repo)
|
||||
app.dependency_overrides[get_auth_repository] = lambda: repo
|
||||
app.dependency_overrides[get_auth_service] = lambda: AuthService(repo, get_settings())
|
||||
monkeypatch.setattr(users_api, "get_portfolio_repository_from_settings", lambda settings: repo)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -83,7 +184,7 @@ def test_list_users_requires_admin(monkeypatch) -> None:
|
||||
|
||||
def test_list_users_returns_payload(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
repo.create_user("alice", email="alice@example.com")
|
||||
repo.create_user("alice", email="alice@example.com", password_hash=hash_password("password123"))
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
response = client.get("/users", headers={"Authorization": "Bearer admin-token"})
|
||||
@@ -97,13 +198,13 @@ def test_list_users_returns_payload(monkeypatch) -> None:
|
||||
|
||||
def test_create_user_rejects_duplicate_username(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
repo.create_user("alice")
|
||||
repo.create_user("alice", password_hash=hash_password("password123"))
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/users",
|
||||
headers={"Authorization": "Bearer admin-token"},
|
||||
json={"username": "alice", "email": "alice2@example.com"},
|
||||
json={"username": "alice", "email": "alice2@example.com", "password": "password123"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -113,7 +214,7 @@ def test_create_user_rejects_duplicate_username(monkeypatch) -> None:
|
||||
|
||||
def test_get_update_and_delete_user_flow(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
user = repo.create_user("alice", email="alice@example.com")
|
||||
user = repo.create_user("alice", email="alice@example.com", password_hash=hash_password("password123"))
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
get_response = client.get(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"})
|
||||
@@ -129,4 +230,33 @@ def test_get_update_and_delete_user_flow(monkeypatch) -> None:
|
||||
assert get_response.json()["username"] == "alice"
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["username"] == "alice-updated"
|
||||
assert delete_response.status_code == 204
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
|
||||
def test_login_and_issue_client_token_flow(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
repo.create_user(
|
||||
"alice",
|
||||
email="alice@example.com",
|
||||
password_hash=hash_password("password123"),
|
||||
permission="trade-write",
|
||||
)
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "alice", "password": "password123"})
|
||||
|
||||
assert login_response.status_code == 200
|
||||
login_payload = login_response.json()
|
||||
session_token = login_payload["access_token"]
|
||||
token_response = client.post(
|
||||
"/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {session_token}"},
|
||||
json={"name": "mobile-app", "permission": "read", "expires_in_days": 30},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert token_response.status_code == 201
|
||||
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"]
|
||||
Reference in New Issue
Block a user