feat: auth & admin
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""Authentication use cases: login, token refresh (rotation), logout, and
|
||||
access-token verification.
|
||||
|
||||
Depends only on domain ports. Wired with concrete adapters at the composition
|
||||
root (``app.api.deps``).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
from app.domain.entities import User
|
||||
from app.domain.errors import AuthenticationError
|
||||
from app.domain.ports import (
|
||||
PasswordHasher,
|
||||
RefreshTokenRepository,
|
||||
TokenService,
|
||||
UserRepository,
|
||||
)
|
||||
from app.domain.tokens import IssuedToken, TokenPair, TokenType
|
||||
|
||||
|
||||
def _hash_token(encoded: str) -> str:
|
||||
"""At-rest hash of a refresh token. A signed JWT is high-entropy, so a fast
|
||||
SHA-256 suffices (no slow KDF needed) — we never store the raw token."""
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
users: UserRepository,
|
||||
refresh_tokens: RefreshTokenRepository,
|
||||
hasher: PasswordHasher,
|
||||
tokens: TokenService,
|
||||
) -> None:
|
||||
self._users = users
|
||||
self._refresh_tokens = refresh_tokens
|
||||
self._hasher = hasher
|
||||
self._tokens = tokens
|
||||
|
||||
async def login(self, username: str, password: str) -> TokenPair:
|
||||
credentials = await self._users.get_credentials_by_username(username)
|
||||
# Same error whether the user is missing or the password is wrong —
|
||||
# don't leak which usernames exist.
|
||||
if credentials is None:
|
||||
raise AuthenticationError("Invalid username or password.")
|
||||
|
||||
valid, updated_hash = self._hasher.verify_and_update(password, credentials.password_hash)
|
||||
if not valid:
|
||||
raise AuthenticationError("Invalid username or password.")
|
||||
if not credentials.user.is_active:
|
||||
raise AuthenticationError("Account is disabled.")
|
||||
if updated_hash is not None:
|
||||
await self._users.set_password_hash(credentials.user.id, updated_hash)
|
||||
|
||||
return await self._issue_pair(credentials.user.id)
|
||||
|
||||
async def refresh(self, encoded_refresh: str) -> TokenPair:
|
||||
claims = self._tokens.decode(encoded_refresh)
|
||||
if claims.token_type is not TokenType.REFRESH:
|
||||
raise AuthenticationError("Not a refresh token.")
|
||||
if not await self._refresh_tokens.is_valid(claims.jti):
|
||||
raise AuthenticationError("Refresh token is revoked or expired.")
|
||||
|
||||
user = await self._users.get_by_id(claims.subject)
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("Account is unavailable.")
|
||||
|
||||
# Rotation: invalidate the presented token before issuing a new pair.
|
||||
await self._refresh_tokens.revoke(claims.jti)
|
||||
return await self._issue_pair(user.id)
|
||||
|
||||
async def logout(self, encoded_refresh: str) -> None:
|
||||
# Best-effort: a malformed/expired token simply has nothing to revoke.
|
||||
try:
|
||||
claims = self._tokens.decode(encoded_refresh)
|
||||
except AuthenticationError:
|
||||
return
|
||||
if claims.token_type is TokenType.REFRESH:
|
||||
await self._refresh_tokens.revoke(claims.jti)
|
||||
|
||||
async def authenticate_access(self, encoded_access: str) -> User:
|
||||
claims = self._tokens.decode(encoded_access)
|
||||
if claims.token_type is not TokenType.ACCESS:
|
||||
raise AuthenticationError("Not an access token.")
|
||||
user = await self._users.get_by_id(claims.subject)
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("Account is unavailable.")
|
||||
return user
|
||||
|
||||
async def _issue_pair(self, user_id: uuid.UUID) -> TokenPair:
|
||||
access = self._tokens.issue(subject=user_id, token_type=TokenType.ACCESS)
|
||||
refresh = self._tokens.issue(subject=user_id, token_type=TokenType.REFRESH)
|
||||
await self._persist_refresh(user_id, refresh)
|
||||
return TokenPair(access=access, refresh=refresh)
|
||||
|
||||
async def _persist_refresh(self, user_id: uuid.UUID, refresh: IssuedToken) -> None:
|
||||
await self._refresh_tokens.add(
|
||||
jti=refresh.jti,
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(refresh.encoded),
|
||||
expires_at=refresh.expires_at,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user