"""Authentication service — login, logout, password reset, session management.""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from typing import Any import redis.asyncio as aioredis from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.audit import log_audit from app.core.auth import ( create_session, get_session_data, hash_password, hash_token, invalidate_session, update_session_tenant, verify_password, ) from app.models.auth import PasswordResetToken from app.models.tenant import Tenant from app.models.user import User, UserTenant class AuthService: """Handles authentication operations.""" async def login( self, db: AsyncSession, redis: aioredis.Redis, email: str, password: str, tenant_slug: str | None = None, ) -> tuple[str, str, User, Tenant] | None: """Authenticate user and create session. Returns (session_id, csrf_token, user, tenant) or None. """ # Find user by email — need to check across tenants or use default tenant q = select(User).where(User.email == email, User.is_active == True) # noqa: E712 result = await db.execute(q) user = result.scalar_one_or_none() if user is None: return None if not verify_password(password, user.password_hash): return None # Get user's default tenant or the one matching slug ut_q = select(UserTenant).where(UserTenant.user_id == user.id) if tenant_slug: ut_q = ut_q.join(Tenant, UserTenant.tenant_id == Tenant.id).where( Tenant.slug == tenant_slug ) else: ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712 ut_result = await db.execute(ut_q) user_tenant = ut_result.scalar_one_or_none() # Fallback: just get first tenant membership if user_tenant is None: ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id) ut_result2 = await db.execute(ut_q2) user_tenant = ut_result2.scalar_one_or_none() if user_tenant is None: return None tenant_q = select(Tenant).where(Tenant.id == user_tenant.tenant_id) tenant_result = await db.execute(tenant_q) tenant = tenant_result.scalar_one_or_none() if tenant is None: return None session_id, csrf_token = await create_session(db, redis, user, tenant.id) # Log the login in audit trail await log_audit( db, tenant.id, user.id, "login", "user", user.id, changes={"email": email}, ) return session_id, csrf_token, user, tenant async def logout(self, redis: aioredis.Redis, session_id: str) -> bool: """Invalidate a session.""" await invalidate_session(redis, session_id) return True async def get_current_user_info( self, db: AsyncSession, redis: aioredis.Redis, session_id: str, ) -> dict[str, Any] | None: """Get current user info from session.""" session_data = await get_session_data(redis, session_id) if session_data is None: return None # Fetch tenant name tenant_q = select(Tenant).where(Tenant.id == uuid.UUID(session_data["tenant_id"])) tenant_result = await db.execute(tenant_q) tenant = tenant_result.scalar_one_or_none() return { "user_id": session_data["user_id"], "email": session_data["email"], "name": session_data["name"], "role": session_data["role"], "tenant_id": session_data["tenant_id"], "tenant_name": tenant.name if tenant else None, } async def switch_tenant( self, db: AsyncSession, redis: aioredis.Redis, session_id: str, new_tenant_id: uuid.UUID, ) -> dict[str, Any] | None: """Switch the active tenant for the current session.""" session_data = await get_session_data(redis, session_id) if session_data is None: return None user_id = uuid.UUID(session_data["user_id"]) # Verify user is member of target tenant ut_q = select(UserTenant).where( UserTenant.user_id == user_id, UserTenant.tenant_id == new_tenant_id, ) ut_result = await db.execute(ut_q) if ut_result.scalar_one_or_none() is None: return None updated = await update_session_tenant(redis, session_id, new_tenant_id) if updated is None: return None # Fetch tenant name tenant_q = select(Tenant).where(Tenant.id == new_tenant_id) tenant_result = await db.execute(tenant_q) tenant = tenant_result.scalar_one_or_none() updated["tenant_name"] = tenant.name if tenant else None return updated async def request_password_reset( self, db: AsyncSession, email: str, tenant_id: uuid.UUID | None = None, ) -> bool: """Create a password reset token. Always returns True (no user enumeration).""" q = select(User).where(User.email == email, User.is_active == True) # noqa: E712 result = await db.execute(q) user = result.scalar_one_or_none() if user is None: return True # Don't reveal whether email exists # Invalidate previous unused tokens prev_q = select(PasswordResetToken).where( PasswordResetToken.user_id == user.id, PasswordResetToken.used_at.is_(None), ) prev_result = await db.execute(prev_q) for prev_token in prev_result.scalars().all(): prev_token.used_at = datetime.now(UTC) # Create new token import secrets raw_token = secrets.token_urlsafe(32) token_hash = hash_token(raw_token) settings = get_settings() expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours) reset_token = PasswordResetToken( tenant_id=user.tenant_id, user_id=user.id, token_hash=token_hash, expires_at=expires_at, ) db.add(reset_token) await db.flush() # In production: send email via SMTP. For now, log it. # The raw_token would be in the email link. return True async def confirm_password_reset( self, db: AsyncSession, token: str, new_password: str, ) -> bool: """Reset password using a valid token. Returns True on success.""" token_hash = hash_token(token) q = select(PasswordResetToken).where( PasswordResetToken.token_hash == token_hash, PasswordResetToken.used_at.is_(None), ) result = await db.execute(q) reset_token = result.scalar_one_or_none() if reset_token is None: return False if reset_token.expires_at < datetime.now(UTC): return False # Token expired # Get user user_q = select(User).where(User.id == reset_token.user_id) user_result = await db.execute(user_q) user = user_result.scalar_one_or_none() if user is None: return False # Update password user.password_hash = hash_password(new_password) reset_token.used_at = datetime.now(UTC) await db.flush() return True async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None: """Get the raw (unhashed) reset token for testing purposes. This simulates what would be sent via email. """ # This is a test helper — in production the token goes via email only import secrets q = select(User).where(User.email == email) result = await db.execute(q) user = result.scalar_one_or_none() if user is None: return None raw_token = secrets.token_urlsafe(32) token_hash = hash_token(raw_token) settings = get_settings() expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours) reset_token = PasswordResetToken( tenant_id=user.tenant_id, user_id=user.id, token_hash=token_hash, expires_at=expires_at, ) db.add(reset_token) await db.flush() return raw_token async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None: """Create an already-expired reset token for testing.""" import secrets q = select(User).where(User.email == email) result = await db.execute(q) user = result.scalar_one_or_none() if user is None: return None raw_token = secrets.token_urlsafe(32) token_hash = hash_token(raw_token) expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired reset_token = PasswordResetToken( tenant_id=user.tenant_id, user_id=user.id, token_hash=token_hash, expires_at=expires_at, ) db.add(reset_token) await db.flush() return raw_token auth_service = AuthService()