80 lines
3.3 KiB
Python
80 lines
3.3 KiB
Python
"""User-management use cases: admin CRUD plus self-service password change.
|
|
|
|
Deletion is *soft* (deactivate) — likes, play history and playlists reference
|
|
users via append-only event-logs, so rows must not vanish (plan §4 invariants).
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from app.domain.entities import User
|
|
from app.domain.errors import AlreadyExistsError, AuthenticationError, NotFoundError
|
|
from app.domain.ports import PasswordHasher, RefreshTokenRepository, UserRepository
|
|
|
|
|
|
class UserService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
users: UserRepository,
|
|
refresh_tokens: RefreshTokenRepository,
|
|
hasher: PasswordHasher,
|
|
) -> None:
|
|
self._users = users
|
|
self._refresh_tokens = refresh_tokens
|
|
self._hasher = hasher
|
|
|
|
async def create_user(
|
|
self, *, username: str, password: str, is_superuser: bool = False
|
|
) -> User:
|
|
if await self._users.get_credentials_by_username(username) is not None:
|
|
raise AlreadyExistsError(f"Username {username!r} is taken.")
|
|
return await self._users.add(
|
|
username=username,
|
|
password_hash=self._hasher.hash(password),
|
|
is_superuser=is_superuser,
|
|
)
|
|
|
|
async def list_users(self, *, limit: int = 50, offset: int = 0) -> list[User]:
|
|
return await self._users.list(limit=limit, offset=offset)
|
|
|
|
async def get_user(self, user_id: uuid.UUID) -> User:
|
|
user = await self._users.get_by_id(user_id)
|
|
if user is None:
|
|
raise NotFoundError("User not found.")
|
|
return user
|
|
|
|
async def set_superuser(self, user_id: uuid.UUID, *, is_superuser: bool) -> User:
|
|
await self.get_user(user_id)
|
|
return await self._users.set_superuser(user_id, is_superuser)
|
|
|
|
async def set_active(self, user_id: uuid.UUID, *, is_active: bool) -> User:
|
|
await self.get_user(user_id)
|
|
user = await self._users.set_active(user_id, is_active)
|
|
if not is_active:
|
|
# Deactivating a user kills their sessions immediately.
|
|
await self._refresh_tokens.revoke_all_for_user(user_id)
|
|
return user
|
|
|
|
async def reset_password(self, user_id: uuid.UUID, *, new_password: str) -> None:
|
|
"""Admin-driven password reset. Revokes all sessions."""
|
|
await self.get_user(user_id)
|
|
await self._users.set_password_hash(user_id, self._hasher.hash(new_password))
|
|
await self._refresh_tokens.revoke_all_for_user(user_id)
|
|
|
|
async def deactivate(self, user_id: uuid.UUID) -> User:
|
|
"""Soft delete: disable the account, keep the row for referential history."""
|
|
return await self.set_active(user_id, is_active=False)
|
|
|
|
async def change_password(
|
|
self, user_id: uuid.UUID, *, current_password: str, new_password: str
|
|
) -> None:
|
|
"""Self-service change: verify the current password first."""
|
|
user = await self.get_user(user_id)
|
|
credentials = await self._users.get_credentials_by_username(user.username)
|
|
assert credentials is not None # user exists; fetched by id above
|
|
valid, _ = self._hasher.verify_and_update(current_password, credentials.password_hash)
|
|
if not valid:
|
|
raise AuthenticationError("Current password is incorrect.")
|
|
await self._users.set_password_hash(user_id, self._hasher.hash(new_password))
|
|
await self._refresh_tokens.revoke_all_for_user(user_id)
|