You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
orquestrador/admin_app/services/auth_service.py

241 lines
8.8 KiB
Python

from datetime import datetime, timezone
from admin_app.core import (
AdminAuthenticatedSession,
AdminSecurityService,
AuthenticatedStaffContext,
AuthenticatedStaffPrincipal,
)
from admin_app.db.models import StaffAccount, StaffSession
from admin_app.repositories import StaffAccountRepository, StaffSessionRepository
from admin_app.services.audit_service import AuditService
class AuthService:
def __init__(
self,
account_repository: StaffAccountRepository,
session_repository: StaffSessionRepository,
security_service: AdminSecurityService,
audit_service: AuditService,
):
self.account_repository = account_repository
self.session_repository = session_repository
self.security_service = security_service
self.audit_service = audit_service
def login(
self,
email: str,
password: str,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession | None:
normalized_email = self.normalize_email(email)
account = self.authenticate_account(email=normalized_email, password=password)
if account is None:
self.audit_service.record_login_failed(
email=normalized_email,
ip_address=ip_address,
user_agent=user_agent,
)
return None
session = self._create_authenticated_session(
account,
ip_address=ip_address,
user_agent=user_agent,
)
self.audit_service.record_login_succeeded(
actor_staff_account_id=account.id,
session_id=session.session_id,
email=account.email,
role=account.role.value,
ip_address=ip_address,
user_agent=user_agent,
)
return session
def refresh_session(
self,
refresh_token: str,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession | None:
token_hash = self.security_service.hash_refresh_token(refresh_token)
staff_session = self.session_repository.get_by_refresh_token_hash(token_hash)
if not self._is_session_active(staff_session):
return None
account = self.account_repository.get_by_id(staff_session.staff_account_id)
if account is None or not account.is_active:
return None
return self._rotate_session(
staff_session,
account,
ip_address=ip_address,
user_agent=user_agent,
)
def logout(
self,
session_id: int,
*,
actor_staff_account_id: int | None,
ip_address: str | None,
user_agent: str | None,
) -> bool:
staff_session = self.session_repository.get_by_id(session_id)
if staff_session is None:
return False
if staff_session.revoked_at is None:
staff_session.revoked_at = datetime.now(timezone.utc)
self.session_repository.save(staff_session)
if actor_staff_account_id is not None:
self.audit_service.record_logout_succeeded(
actor_staff_account_id=actor_staff_account_id,
session_id=session_id,
ip_address=ip_address,
user_agent=user_agent,
)
return True
def logout_by_refresh_token(
self,
refresh_token: str,
*,
ip_address: str | None,
user_agent: str | None,
) -> int | None:
token_hash = self.security_service.hash_refresh_token(refresh_token)
staff_session = self.session_repository.get_by_refresh_token_hash(token_hash)
if staff_session is None:
return None
account = self.account_repository.get_by_id(staff_session.staff_account_id)
actor_staff_account_id = account.id if account is not None and account.is_active else None
self.logout(
staff_session.id,
actor_staff_account_id=actor_staff_account_id,
ip_address=ip_address,
user_agent=user_agent,
)
return staff_session.id
def get_authenticated_context(self, access_token: str) -> AuthenticatedStaffContext:
claims = self.security_service.decode_access_token(access_token)
staff_session = self.session_repository.get_by_id(claims.sid)
if not self._is_session_active(staff_session):
raise ValueError("Administrative session is not available")
account = self.account_repository.get_by_id(int(claims.sub))
if account is None or not account.is_active:
raise ValueError("Staff account is not available")
if self.normalize_email(account.email) != self.normalize_email(claims.email):
raise ValueError("Token principal mismatch")
return AuthenticatedStaffContext(
principal=self.build_principal(account),
session_id=staff_session.id,
)
def authenticate_account(self, email: str, password: str) -> StaffAccount | None:
normalized_email = self.normalize_email(email)
account = self.account_repository.get_by_email(normalized_email)
if account is None or not account.is_active:
return None
if not self.security_service.verify_password(password, account.password_hash):
return None
account.last_login_at = datetime.now(timezone.utc)
return self.account_repository.update_last_login(account)
@staticmethod
def normalize_email(email: str) -> str:
return email.strip().lower()
@staticmethod
def build_principal(account: StaffAccount) -> AuthenticatedStaffPrincipal:
return AuthenticatedStaffPrincipal(
id=account.id,
email=account.email,
display_name=account.display_name,
role=account.role,
is_active=account.is_active,
)
def _create_authenticated_session(
self,
account: StaffAccount,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession:
refresh_token = self.security_service.generate_refresh_token()
refresh_token_hash = self.security_service.hash_refresh_token(refresh_token)
expires_at = self.security_service.build_refresh_token_expiry()
staff_session = self.session_repository.create(
staff_account_id=account.id,
refresh_token_hash=refresh_token_hash,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent,
)
principal = self.build_principal(account)
access_token = self.security_service.issue_access_token(principal, staff_session.id)
return AdminAuthenticatedSession(
session_id=staff_session.id,
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in_seconds=self.security_service.settings.admin_auth_access_token_ttl_minutes * 60,
principal=principal,
)
def _rotate_session(
self,
staff_session: StaffSession,
account: StaffAccount,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession:
refresh_token = self.security_service.generate_refresh_token()
staff_session.refresh_token_hash = self.security_service.hash_refresh_token(refresh_token)
staff_session.expires_at = self.security_service.build_refresh_token_expiry()
staff_session.last_used_at = datetime.now(timezone.utc)
staff_session.ip_address = ip_address
staff_session.user_agent = user_agent
persisted_session = self.session_repository.save(staff_session)
principal = self.build_principal(account)
access_token = self.security_service.issue_access_token(principal, persisted_session.id)
return AdminAuthenticatedSession(
session_id=persisted_session.id,
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in_seconds=self.security_service.settings.admin_auth_access_token_ttl_minutes * 60,
principal=principal,
)
@staticmethod
def _normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@classmethod
def _is_session_active(cls, staff_session: StaffSession | None) -> bool:
if staff_session is None:
return False
if staff_session.revoked_at is not None:
return False
expires_at = cls._normalize_datetime(staff_session.expires_at)
return expires_at >= datetime.now(timezone.utc)