diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..6e4aa0e --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,126 @@ +"""Auth service: register, login, token generation.""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.security import create_access_token, hash_password, verify_password +from app.models.org import Org +from app.models.user import User, UserRole +from app.schemas.auth import UserRegisterRequest + + +class AuthError(Exception): + """Base for auth service errors with an HTTP-friendly status code.""" + + status_code: int = 400 + + +class EmailAlreadyExists(AuthError): + status_code = 409 + + +class BootstrapAlreadyCompleted(AuthError): + status_code = 403 + + +class InvalidCredentials(AuthError): + status_code = 401 + + +async def count_users(db: AsyncSession) -> int: + """Count active (non-soft-deleted) users. Used to gate bootstrap registration.""" + result = await db.execute( + select(User).where(User.deleted_at.is_(None)) + ) + return len(result.scalars().all()) + + +async def register_user( + db: AsyncSession, payload: UserRegisterRequest +) -> tuple[User, str]: + """Bootstrap registration. + + Creates a new Org and the first user (or a new user in the existing org + if the org is passed in — Phase 4a only supports bootstrap here). + + Raises: + BootstrapAlreadyCompleted: if users already exist (403). + EmailAlreadyExists: if the email is already taken (409). + + Returns: + (user, jwt_token) tuple. + """ + existing = await count_users(db) + if existing > 0: + raise BootstrapAlreadyCompleted( + "Bootstrap registration is disabled: users already exist. " + "Use POST /api/v1/users (admin) to invite new users." + ) + + # Check email uniqueness within the (new) org context + org = Org(name=f"{payload.name}'s Org") + db.add(org) + await db.flush() # assigns org.id + + user = User( + org_id=org.id, + email=payload.email.lower(), + password_hash=hash_password(payload.password), + name=payload.name, + # First registered user is implicitly admin for bootstrap convenience. + role=UserRole.admin, + ) + db.add(user) + try: + await db.commit() + except IntegrityError as e: + await db.rollback() + raise EmailAlreadyExists( + f"A user with email {payload.email!r} already exists." + ) from e + + await db.refresh(user) + + role_str = user.role.value if hasattr(user.role, "value") else str(user.role) + token = create_access_token(user.id, user.org_id, role_str) + return user, token + + +async def authenticate_user( + db: AsyncSession, email: str, password: str +) -> Optional[tuple[User, str]]: + """Verify credentials and return (user, token) on success, None on failure. + + The endpoint wraps this and returns 401 on None — keeping the + service-level function free of HTTPException for testability. + """ + result = await db.execute( + select(User).where( + User.email == email.lower(), + User.deleted_at.is_(None), + ) + ) + user = result.scalar_one_or_none() + + if user is None: + return None + + if not verify_password(password, user.password_hash): + return None + + role_str = user.role.value if hasattr(user.role, "value") else str(user.role) + token = create_access_token(user.id, user.org_id, role_str) + return user, token + + +def build_token_response(user: User) -> str: + """Build a fresh access token for a user.""" + settings = get_settings() + _ = settings # touch to ensure config is loaded + return create_access_token(user.id, user.org_id, user.role.value)