Files
leocrm/app/services/auth_service.py
T

127 lines
3.6 KiB
Python
Raw Normal View History

2026-06-04 00:06:25 +00:00
"""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)