Files
F-r-ldraledigplannerare/backend/app/services/auth_service.py
T
jacobh2 0e9b20ec37 Track preschool planning days, partner invites and real vacation forfeiture
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>
2026-09-20 11:22:07 +02:00

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)