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 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 _is_session_active(staff_session: StaffSession | None) -> bool: if staff_session is None: return False if staff_session.revoked_at is not None: return False return staff_session.expires_at >= datetime.now(timezone.utc)