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.
241 lines
8.8 KiB
Python
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)
|