0e9b20ec37
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
11 KiB
Python
253 lines
11 KiB
Python
"""Authentication service.
|
|
|
|
Handles registration, login, token generation, and current-user lookup.
|
|
Passwords hashed with argon2 (via argon2-cffi). Tokens are short-lived JWTs
|
|
(access) + httpOnly refresh tokens. Refresh tokens are NOT persisted in MVP
|
|
(stateless — revoke by letting them expire). Add a refresh_token table when
|
|
true revocation is needed.
|
|
|
|
TODO (post-MVP): password reset + email verification flows are not implemented.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID, uuid4
|
|
|
|
import jwt
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import VerifyMismatchError
|
|
|
|
from app.config import settings
|
|
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 (
|
|
WorkspaceInvitationRepository,
|
|
WorkspaceRepository,
|
|
)
|
|
from app.utils.errors import AuthError, ConflictError, InvitationError, 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__(
|
|
self,
|
|
user_repo: UserRepository,
|
|
personal_data_repo: PersonalDataRepository,
|
|
workspace_repo: WorkspaceRepository,
|
|
uow: UnitOfWork | None = None,
|
|
inv_repo: WorkspaceInvitationRepository | None = None,
|
|
) -> None:
|
|
self._users = user_repo
|
|
self._personal_data = personal_data_repo
|
|
self._workspaces = workspace_repo
|
|
# Optional: only needed for `register(..., invite_token=...)`. Kept
|
|
# optional (rather than required) so pre-existing direct-construction
|
|
# call sites (scripts/seed_demo.py, some test fixtures) that never
|
|
# exercise the invite-registration path keep working unchanged.
|
|
self._inv = inv_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,
|
|
invite_token: str | None = None,
|
|
) -> tuple[User, PersonalData]:
|
|
"""Create a User, linked to a PersonalData profile.
|
|
|
|
Without `invite_token`: creates a brand-new personal workspace +
|
|
PersonalData, as before.
|
|
|
|
With `invite_token`: skips creating a new workspace altogether and
|
|
joins the inviting workspace directly, mirroring
|
|
`WorkspaceService.redeem_invitation`'s claim logic —
|
|
- a placeholder member (no linked User) in that workspace is claimed
|
|
(the new User links straight to it; its display name is updated to
|
|
`display_name`), preserving any plan data already recorded against
|
|
it;
|
|
- otherwise, if the workspace has a free slot, a fresh PersonalData
|
|
row is created there instead of in a new workspace;
|
|
- otherwise (both slots already have real logins) the invitation is
|
|
rejected.
|
|
|
|
Raises ConflictError if the email is taken, InvitationError for an
|
|
invalid/expired/full/unconfigured invitation.
|
|
"""
|
|
existing = await self._users.get_by_email(email)
|
|
if existing is not None:
|
|
raise ConflictError("Email already registered")
|
|
|
|
# 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)
|
|
|
|
inv = None
|
|
placeholder: PersonalData | None = None
|
|
if invite_token is not None:
|
|
if self._inv is None:
|
|
raise InvitationError("Invitations are not available")
|
|
token_hash = hashlib.sha256(invite_token.encode()).hexdigest()
|
|
inv = await self._inv.get_by_token_hash(token_hash)
|
|
if inv is None:
|
|
raise InvitationError("Invitation not found")
|
|
if inv.status != "pending":
|
|
raise InvitationError(f"Invitation is {inv.status}")
|
|
if datetime.now() > inv.expires_at:
|
|
inv.status = "expired"
|
|
await self._inv.update(inv.id, inv)
|
|
raise InvitationError("Invitation has expired")
|
|
|
|
existing_members = await self._personal_data.list_by_workspace(inv.workspace_id)
|
|
for member in existing_members:
|
|
if await self._users.get_by_personal_data_uuid(member.uuid) is None:
|
|
placeholder = member
|
|
break
|
|
if placeholder is None and len(existing_members) >= 2:
|
|
raise InvitationError("This workspace already has its maximum number of members")
|
|
|
|
# Workspace/PersonalData creation (or placeholder claim) + User +
|
|
# invitation acceptance must land together: a crash partway through
|
|
# would otherwise leave an orphaned workspace, a personal_data row
|
|
# with no login, a user pointing at nothing (FK ON DELETE CASCADE),
|
|
# or an invitation stuck out of step with membership.
|
|
async with self._uow():
|
|
if inv is not None:
|
|
if placeholder is not None:
|
|
pd = placeholder
|
|
if pd.name != display_name:
|
|
pd.name = display_name
|
|
pd = await self._personal_data.update(pd.uuid, pd)
|
|
else:
|
|
pd = PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=inv.workspace_id,
|
|
name=display_name,
|
|
created_at=datetime.now(),
|
|
)
|
|
pd = await self._personal_data.create(pd)
|
|
|
|
inv.status = "accepted"
|
|
inv.accepted_at = datetime.now()
|
|
assert self._inv is not None
|
|
await self._inv.update(inv.id, inv)
|
|
else:
|
|
workspace = Workspace(
|
|
id=uuid4(),
|
|
name=f"{display_name}s planering",
|
|
created_at=datetime.now(),
|
|
)
|
|
workspace = await self._workspaces.create(workspace)
|
|
|
|
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/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:
|
|
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 = await asyncio.to_thread(_ph.hash, password)
|
|
await self._users.update(user.id, user)
|
|
return user
|
|
|
|
def create_access_token(self, user_id: UUID) -> str:
|
|
expire = datetime.now(timezone.utc) + timedelta(
|
|
minutes=settings.jwt_access_token_expire_minutes
|
|
)
|
|
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
|
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
|
|
|
def create_refresh_token(self, user_id: UUID) -> str:
|
|
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
|
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
|
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
|
|
|
async def get_user_from_token(self, token: str) -> User:
|
|
"""Decode a JWT access token and return the user. Raises UnauthorizedError on failure."""
|
|
try:
|
|
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
|
except jwt.ExpiredSignatureError as exc:
|
|
raise UnauthorizedError("Token expired") from exc
|
|
except jwt.InvalidTokenError as exc:
|
|
raise UnauthorizedError("Invalid token") from exc
|
|
|
|
if payload.get("type") != "access":
|
|
raise UnauthorizedError("Expected access token")
|
|
|
|
user_id = UUID(payload["sub"])
|
|
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:
|
|
"""Issue a new access token from a valid refresh token."""
|
|
try:
|
|
payload = jwt.decode(
|
|
refresh_token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
|
)
|
|
except jwt.InvalidTokenError as exc:
|
|
raise UnauthorizedError("Invalid refresh token") from exc
|
|
|
|
if payload.get("type") != "refresh":
|
|
raise UnauthorizedError("Expected refresh token")
|
|
|
|
user_id = UUID(payload["sub"])
|
|
user = await self._users.get_by_id(user_id)
|
|
if user is None:
|
|
raise UnauthorizedError("User not found")
|
|
return self.create_access_token(user.id)
|