Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions backend/src/timeflow/business/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""账户认证契约与应用服务。"""

from timeflow.business.auth.contracts import (
AccessTokenService,
AccountRecord,
AccountRepositoryPort,
AuthAccessResult,
AuthCredentials,
AuthError,
AuthErrorCode,
AuthUnitOfWork,
AuthUnitOfWorkFactory,
IssuedAccessTokenView,
NewAccount,
PasswordHasher,
UsernameConflictError,
)
from timeflow.business.auth.service import AuthAccessService

__all__ = [
"AccessTokenService",
"AccountRecord",
"AccountRepositoryPort",
"AuthAccessResult",
"AuthAccessService",
"AuthCredentials",
"AuthError",
"AuthErrorCode",
"AuthUnitOfWork",
"AuthUnitOfWorkFactory",
"IssuedAccessTokenView",
"NewAccount",
"PasswordHasher",
"UsernameConflictError",
]
170 changes: 170 additions & 0 deletions backend/src/timeflow/business/auth/contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""与框架无关的账户认证契约。"""

from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from types import TracebackType
from typing import Protocol, Self


class AuthErrorCode(StrEnum):
"""供入站适配器共用的稳定认证错误码。"""

INVALID_USERNAME = "AUTH_INVALID_USERNAME"
INVALID_PASSWORD = "AUTH_INVALID_PASSWORD"
INVALID_CREDENTIALS = "AUTH_INVALID_CREDENTIALS"

@property
def message(self) -> str:
"""返回共享契约为该错误码固定的公开消息。"""
return _AUTH_ERROR_MESSAGES[self]


_AUTH_ERROR_MESSAGES = {
AuthErrorCode.INVALID_USERNAME: "Invalid username",
AuthErrorCode.INVALID_PASSWORD: "Invalid password",
AuthErrorCode.INVALID_CREDENTIALS: "Invalid username or password",
}


class AuthError(Exception):
"""可按错误码安全映射的预期认证异常。"""

__slots__ = ("code",)

def __init__(self, code: AuthErrorCode) -> None:
super().__init__(code.message)
self.code = code

@property
def message(self) -> str:
"""保留异常调用方使用的稳定消息视图。"""
return self.code.message


@dataclass(frozen=True, slots=True)
class AuthCredentials:
"""规范化凭据,且密码不会出现在对象表示中。"""

username: str
password: str = field(repr=False)

def __post_init__(self) -> None:
if not isinstance(self.username, str):
raise AuthError(AuthErrorCode.INVALID_USERNAME)
username = self.username.strip()
if not 3 <= len(username) <= 64:
raise AuthError(AuthErrorCode.INVALID_USERNAME)
if not isinstance(self.password, str):
raise AuthError(AuthErrorCode.INVALID_PASSWORD)
if not 8 <= len(self.password) <= 128 or self.password.isspace():
raise AuthError(AuthErrorCode.INVALID_PASSWORD)
object.__setattr__(self, "username", username)


@dataclass(frozen=True, slots=True)
class AccountRecord:
"""密码验证所需且与持久化无关的账户数据。"""

id: str
username: str
password_hash: str = field(repr=False)


@dataclass(frozen=True, slots=True)
class NewAccount:
"""新增账户所需且与持久化无关的字段。"""

id: str
username: str
password_hash: str = field(repr=False)
created_at: datetime
updated_at: datetime


@dataclass(frozen=True, slots=True)
class AuthAccessResult:
"""返回给入站适配器的账户访问成功结果。"""

account_id: str
access_token: str = field(repr=False)
expires_in: int


class UsernameConflictError(RuntimeError):
"""其他事务已占用请求的用户名。"""


class AccountRepositoryPort(Protocol):
"""认证所需的账户持久化操作。"""

def get_by_username(self, username: str) -> AccountRecord | None: ...

def add(self, account: NewAccount) -> AccountRecord: ...


class AuthUnitOfWork(Protocol):
"""单个隔离的认证事务。"""

@property
def accounts(self) -> AccountRepositoryPort: ...

def __enter__(self) -> Self: ...

def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None: ...

def commit(self) -> None: ...

def rollback(self) -> None: ...


AuthUnitOfWorkFactory = Callable[[], AuthUnitOfWork]


class PasswordHasher(Protocol):
"""隐藏具体算法的密码哈希与验证接口。"""

def hash(self, password: str) -> str: ...

def verify(self, password: str, password_hash: str) -> bool: ...


class IssuedAccessTokenView(Protocol):
"""新签发访问令牌的只读视图。"""

@property
def access_token(self) -> str: ...

@property
def expires_in(self) -> int: ...


class AccessTokenService(Protocol):
"""通过同一安全适配器签发并验证访问令牌。"""

def issue(self, account_id: str) -> IssuedAccessTokenView: ...

def verify(self, token: str) -> str | None: ...


__all__ = [
"AccessTokenService",
"AccountRecord",
"AccountRepositoryPort",
"AuthAccessResult",
"AuthCredentials",
"AuthError",
"AuthErrorCode",
"AuthUnitOfWork",
"AuthUnitOfWorkFactory",
"IssuedAccessTokenView",
"NewAccount",
"PasswordHasher",
"UsernameConflictError",
]
102 changes: 102 additions & 0 deletions backend/src/timeflow/business/auth/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""账户创建与密码访问应用服务。"""

from collections.abc import Callable
from datetime import UTC, datetime
from uuid import uuid4

from timeflow.business.auth.contracts import (
AccessTokenService,
AccountRecord,
AuthAccessResult,
AuthCredentials,
AuthError,
AuthErrorCode,
AuthUnitOfWorkFactory,
NewAccount,
PasswordHasher,
UsernameConflictError,
)


class AuthAccessService:
"""创建新账户,或认证已存在用户名对应的账户。"""

def __init__(
self,
unit_of_work_factory: AuthUnitOfWorkFactory,
password_hasher: PasswordHasher,
access_token_service: AccessTokenService,
*,
clock: Callable[[], datetime] | None = None,
id_factory: Callable[[], str] | None = None,
) -> None:
self._unit_of_work_factory = unit_of_work_factory
self._password_hasher = password_hasher
self._access_token_service = access_token_service
self._clock = clock or (lambda: datetime.now(UTC))
self._id_factory = id_factory or (lambda: uuid4().hex)

def access(self, username: str, password: str) -> AuthAccessResult:
"""规范化凭据,持久化或验证账户,然后签发令牌。"""
credentials = AuthCredentials(username, password)
account, requires_verification = self._get_or_create(credentials)
if requires_verification and not self._password_hasher.verify(
credentials.password, account.password_hash
):
raise AuthError(AuthErrorCode.INVALID_CREDENTIALS)

issued = self._access_token_service.issue(account.id)
return AuthAccessResult(account.id, issued.access_token, issued.expires_in)

def _get_or_create(self, credentials: AuthCredentials) -> tuple[AccountRecord, bool]:
existing = self._read_account(credentials.username)
if existing is not None:
return existing, True

password_hash = self._password_hasher.hash(credentials.password)
return self._create_or_read_winner(credentials.username, password_hash)

def _read_account(self, username: str) -> AccountRecord | None:
with self._unit_of_work_factory() as unit_of_work:
return unit_of_work.accounts.get_by_username(username)

def _create_or_read_winner(
self,
username: str,
password_hash: str,
) -> tuple[AccountRecord, bool]:
try:
with self._unit_of_work_factory() as unit_of_work:
existing = unit_of_work.accounts.get_by_username(username)
if existing is not None:
return existing, True

now = self._clock()
created = unit_of_work.accounts.add(
Comment thread
Alexander-Noah marked this conversation as resolved.
NewAccount(
id=self._new_account_id(),
username=username,
password_hash=password_hash,
created_at=now,
updated_at=now,
)
)
unit_of_work.commit()
return created, False
except UsernameConflictError:
return self._read_conflict_winner(username), True

def _read_conflict_winner(self, username: str) -> AccountRecord:
winner = self._read_account(username)
if winner is None:
raise RuntimeError("Username conflict winner was not visible in a new transaction")
return winner

def _new_account_id(self) -> str:
account_id = f"acc_{self._id_factory()}"
if len(account_id) > 64:
raise RuntimeError("Generated account id exceeds the persistence limit")
return account_id


__all__ = ["AuthAccessService"]
67 changes: 67 additions & 0 deletions backend/src/timeflow/data/account_uow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""账户认证的 SQLAlchemy 事务适配器。"""

from types import TracebackType

from sqlalchemy.orm import Session, sessionmaker

from timeflow.business.auth import AccountRepositoryPort
from timeflow.data.repositories.account import AccountRepository


class SqlAlchemyAuthUnitOfWork:
"""每个认证事务仅持有一个 Session。"""

def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
self._session: Session | None = None
self._accounts: AccountRepository | None = None
self._committed = False

@property
def accounts(self) -> AccountRepositoryPort:
"""返回当前事务的仓储,事务外访问会失败。"""
if self._accounts is None:
raise RuntimeError("The authentication unit of work is not active")
return self._accounts

def __enter__(self) -> "SqlAlchemyAuthUnitOfWork":
if self._session is not None:
raise RuntimeError("The authentication unit of work is already active")
self._session = self._session_factory()
self._committed = False
self._accounts = AccountRepository(self._session)
return self

def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
session = self._require_session()
try:
if exc_type is not None or not self._committed:
session.rollback()
finally:
if self._accounts is not None:
self._accounts.close()
session.close()
Comment thread
Alexander-Noah marked this conversation as resolved.
self._session = None
self._accounts = None
self._committed = False

def commit(self) -> None:
self._require_session().commit()
self._committed = True

def rollback(self) -> None:
self._require_session().rollback()
self._committed = False

def _require_session(self) -> Session:
if self._session is None:
raise RuntimeError("The authentication unit of work is not active")
return self._session


__all__ = ["SqlAlchemyAuthUnitOfWork"]
3 changes: 2 additions & 1 deletion backend/src/timeflow/data/repositories/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Concrete database repositories."""

from timeflow.data.repositories.account import AccountRepository
from timeflow.data.repositories.schedule import (
ScheduleRepository,
ScheduleRevisionConflictError,
)

__all__ = ["ScheduleRepository", "ScheduleRevisionConflictError"]
__all__ = ["AccountRepository", "ScheduleRepository", "ScheduleRevisionConflictError"]
Loading
Loading