feat: auth & admin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Domain entities and value objects — pure, framework-free."""
|
||||
|
||||
from app.domain.entities.user import Credentials, User
|
||||
|
||||
__all__ = ["Credentials", "User"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""User entity.
|
||||
|
||||
Admin is a single ``is_superuser`` flag — no role system in Phase 1 (kept
|
||||
deliberately minimal; granular permissions are deferred, see plan §3.5).
|
||||
``User`` is the outward-facing entity and never carries the password hash;
|
||||
the hash lives on :class:`Credentials`, used only inside the auth service.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class User:
|
||||
"""A person with access to the instance. The password hash is intentionally
|
||||
absent — see :class:`Credentials`."""
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
is_superuser: bool
|
||||
is_active: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Credentials:
|
||||
"""A user paired with their stored password hash. Stays inside the
|
||||
application layer — never serialized to clients."""
|
||||
|
||||
user: User
|
||||
password_hash: str
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Ports — the contracts the application layer depends on.
|
||||
|
||||
These are Protocols, not implementations. Concrete adapters live in
|
||||
``app.infrastructure`` (repositories) and ``app.core.security`` (crypto) and
|
||||
are bound to these ports at the composition root (``app.api.deps``).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.entities import Credentials, User
|
||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None: ...
|
||||
async def get_credentials_by_username(self, username: str) -> Credentials | None: ...
|
||||
async def add(self, *, username: str, password_hash: str, is_superuser: bool) -> User: ...
|
||||
async def list(self, *, limit: int, offset: int) -> list[User]: ...
|
||||
async def set_password_hash(self, user_id: uuid.UUID, password_hash: str) -> None: ...
|
||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
||||
async def count(self) -> int: ...
|
||||
|
||||
|
||||
class RefreshTokenRepository(Protocol):
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
jti: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
token_hash: str,
|
||||
expires_at: dt.datetime,
|
||||
) -> None: ...
|
||||
async def is_valid(self, jti: uuid.UUID) -> bool:
|
||||
"""True iff a row exists for ``jti`` that is neither revoked nor expired."""
|
||||
...
|
||||
|
||||
async def revoke(self, jti: uuid.UUID) -> None: ...
|
||||
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None: ...
|
||||
|
||||
|
||||
class PasswordHasher(Protocol):
|
||||
def hash(self, password: str) -> str: ...
|
||||
def verify_and_update(self, password: str, password_hash: str) -> tuple[bool, str | None]:
|
||||
"""Verify ``password`` against ``password_hash``. Returns
|
||||
``(is_valid, updated_hash)`` where ``updated_hash`` is a fresh hash to
|
||||
persist when the stored one uses outdated parameters, else ``None``."""
|
||||
...
|
||||
|
||||
|
||||
class TokenService(Protocol):
|
||||
def issue(self, *, subject: uuid.UUID, token_type: TokenType) -> IssuedToken: ...
|
||||
def decode(self, encoded: str) -> TokenClaims:
|
||||
"""Verify signature + expiry and return claims. Raises
|
||||
:class:`~app.domain.errors.AuthenticationError` on any failure."""
|
||||
...
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Token value objects — framework-free.
|
||||
|
||||
The auth flow issues a short-lived *access* token and a long-lived *refresh*
|
||||
token (offline-first: clients may stay disconnected for weeks). Refresh tokens
|
||||
are persisted and revocable (see :class:`~app.domain.ports.RefreshTokenRepository`);
|
||||
access tokens are stateless and verified by signature + expiry alone.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import enum
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class TokenType(enum.StrEnum):
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenClaims:
|
||||
"""Decoded, verified claims from a JWT."""
|
||||
|
||||
subject: uuid.UUID # user id (``sub``)
|
||||
token_type: TokenType
|
||||
jti: uuid.UUID
|
||||
expires_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IssuedToken:
|
||||
"""A freshly minted token: the encoded string plus the metadata needed to
|
||||
persist/track it (jti, expiry)."""
|
||||
|
||||
encoded: str
|
||||
jti: uuid.UUID
|
||||
expires_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenPair:
|
||||
"""The access + refresh pair returned to clients on login/refresh."""
|
||||
|
||||
access: IssuedToken
|
||||
refresh: IssuedToken
|
||||
Reference in New Issue
Block a user