Harden the API and the data layer after a security and performance audit
Security - Every route now requires a login and refuses ids outside the caller's workspace with 404: plan, leave-wishes, day-overrides and vacation-days were fully anonymous; every other router trusted client-supplied workspace/child/parent ids. Scraper sessions are bound to their owner. - Invitations can no longer be created for a foreign workspace, and a redeemer who is already a member (or a full workspace) is refused. - Production refuses the default JWT secret; API docs are hidden in production; a failed migration aborts startup instead of serving. - Login and register no longer reveal whether an email exists; inactive accounts are rejected; /auth/refresh is rate limited. - The FK scraper writes debug artefacts only when explicitly enabled, logs no scraped values or page text, clears the BankID QR as soon as it is scanned and purges finished sessions after five minutes. - Real FK page captures are removed from the tree (history rewrite follows); the parser tests use a synthetic fixture. Performance and correctness - Plan apply/sync run inside one locked transaction with batched writes instead of one commit per row, so a failure mid-batch leaves nothing half-applied and concurrent syncs no longer corrupt each other. - SQLite runs in WAL mode with a busy timeout; 14 indexes cover the filters that were full scans; argon2 hashing runs off the event loop; responses are gzipped. - Frontend: a 401 now refreshes the token once and retries, so sessions no longer die silently after 15 minutes; every data hook discards stale responses; logout clears state even when the server call fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
This commit is contained in:
@@ -11,6 +11,7 @@ TODO (post-MVP): password reset + email verification flows are not implemented.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -23,12 +24,19 @@ from app.entities.personal_data import PersonalData
|
||||
from app.entities.user import User
|
||||
from app.entities.workspace import Workspace
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.workspace_repository import WorkspaceRepository
|
||||
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
# Precomputed at import time so a login against an unknown email still pays the
|
||||
# cost of an argon2 verify (against a hash nobody's password can match) — this
|
||||
# keeps the response-time profile of "unknown email" indistinguishable from
|
||||
# "known email, wrong password" and closes the user-enumeration timing oracle.
|
||||
_DUMMY_HASH = _ph.hash(uuid4().hex)
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(
|
||||
@@ -36,10 +44,16 @@ class AuthService:
|
||||
user_repo: UserRepository,
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
workspace_repo: WorkspaceRepository,
|
||||
uow: UnitOfWork | None = None,
|
||||
) -> None:
|
||||
self._users = user_repo
|
||||
self._personal_data = personal_data_repo
|
||||
self._workspaces = workspace_repo
|
||||
# Defaults to a no-op so pre-existing direct-construction call sites
|
||||
# (scripts/seed_demo.py, test fixtures) keep today's per-write-commit
|
||||
# behavior; `app.dependencies.get_auth_service` always wires a real
|
||||
# `SQLiteUnitOfWork`.
|
||||
self._uow = uow or NoOpUnitOfWork()
|
||||
|
||||
async def register(
|
||||
self, email: str, password: str, display_name: str
|
||||
@@ -47,45 +61,66 @@ class AuthService:
|
||||
"""Create User + personal workspace + PersonalData. Raises ConflictError if email taken."""
|
||||
existing = await self._users.get_by_email(email)
|
||||
if existing is not None:
|
||||
raise ConflictError(f"Email already registered: {email}")
|
||||
raise ConflictError("Email already registered")
|
||||
|
||||
# Create a personal workspace for the registering user.
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
# Hash before opening the unit of work: argon2 is CPU work
|
||||
# (offloaded via to_thread), not a DB write — no reason to hold the
|
||||
# write lock for it.
|
||||
hashed_password = await asyncio.to_thread(_ph.hash, password)
|
||||
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
# Workspace + PersonalData + User must land together: a crash between
|
||||
# any two of these would otherwise leave an orphaned workspace, a
|
||||
# personal_data row with no login, or (with the FK ON DELETE CASCADE)
|
||||
# a user pointing at nothing.
|
||||
async with self._uow():
|
||||
# Create a personal workspace for the registering user.
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=pd.uuid,
|
||||
email=email,
|
||||
hashed_password=_ph.hash(password),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
user = await self._users.create(user)
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=pd.uuid,
|
||||
email=email,
|
||||
hashed_password=hashed_password,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
user = await self._users.create(user)
|
||||
return user, pd
|
||||
|
||||
async def login(self, email: str, password: str) -> User:
|
||||
"""Verify credentials. Raises AuthError on wrong email or password."""
|
||||
"""Verify credentials. Raises AuthError on wrong email/password/inactive account.
|
||||
|
||||
Always uses a generic message so unknown-email and wrong-password are
|
||||
indistinguishable, and always runs an argon2 verify (real or dummy) so
|
||||
the two cases take comparable time.
|
||||
"""
|
||||
user = await self._users.get_by_email(email)
|
||||
if user is None:
|
||||
try:
|
||||
await asyncio.to_thread(_ph.verify, _DUMMY_HASH, password)
|
||||
except VerifyMismatchError:
|
||||
pass
|
||||
raise AuthError("Invalid email or password")
|
||||
try:
|
||||
_ph.verify(user.hashed_password, password)
|
||||
await asyncio.to_thread(_ph.verify, user.hashed_password, password)
|
||||
except VerifyMismatchError as exc:
|
||||
raise AuthError("Invalid email or password") from exc
|
||||
if not user.is_active:
|
||||
raise UnauthorizedError("Account is inactive")
|
||||
if _ph.check_needs_rehash(user.hashed_password):
|
||||
user.hashed_password = _ph.hash(password)
|
||||
user.hashed_password = await asyncio.to_thread(_ph.hash, password)
|
||||
await self._users.update(user.id, user)
|
||||
return user
|
||||
|
||||
@@ -117,6 +152,8 @@ class AuthService:
|
||||
user = await self._users.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise UnauthorizedError("User not found")
|
||||
if not user.is_active:
|
||||
raise UnauthorizedError("Account is inactive")
|
||||
return user
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user