Upload app/services/auth_service.py
This commit is contained in:
@@ -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)
|
||||||
Reference in New Issue
Block a user