From dd6489ea144959657710935fcac6e2a3a964d764 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Mon, 2 Mar 2026 12:21:43 +0300 Subject: [PATCH 1/7] refactor: clean up imports and reorganize schema definitions in auth and mfa modules --- app/api/auth/adapters/auth.py | 7 +- app/api/auth/adapters/mfa.py | 2 +- app/api/auth/router_auth.py | 7 +- app/api/auth/router_mfa.py | 2 +- app/api/auth/schemas.py | 80 ++++++++++++++++ app/ldap_protocol/auth/auth_manager.py | 3 +- app/ldap_protocol/auth/mfa_manager.py | 7 +- app/ldap_protocol/auth/schemas.py | 96 +------------------ .../policies/audit/dataclasses.py | 8 ++ app/ldap_protocol/policies/audit/monitor.py | 4 +- 10 files changed, 101 insertions(+), 115 deletions(-) create mode 100644 app/api/auth/schemas.py diff --git a/app/api/auth/adapters/auth.py b/app/api/auth/adapters/auth.py index 50ed85ee7..e1f9f3621 100644 --- a/app/api/auth/adapters/auth.py +++ b/app/api/auth/adapters/auth.py @@ -9,14 +9,11 @@ from adaptix.conversion import get_converter from fastapi import Request +from api.auth.schemas import OAuth2Form, SetupRequest from api.base_adapter import BaseAdapter from ldap_protocol.auth import AuthManager from ldap_protocol.auth.dto import SetupDTO -from ldap_protocol.auth.schemas import ( - MFAChallengeResponse, - OAuth2Form, - SetupRequest, -) +from ldap_protocol.auth.schemas import MFAChallengeResponse from ldap_protocol.dialogue import UserSchema _convert_request_to_dto = get_converter(SetupRequest, SetupDTO) diff --git a/app/api/auth/adapters/mfa.py b/app/api/auth/adapters/mfa.py index 9fa3b4a02..5183cb782 100644 --- a/app/api/auth/adapters/mfa.py +++ b/app/api/auth/adapters/mfa.py @@ -9,10 +9,10 @@ from fastapi import status from fastapi.responses import RedirectResponse +from api.auth.schemas import MFACreateRequest, MFAGetResponse from api.base_adapter import BaseAdapter from ldap_protocol.auth import MFAManager from ldap_protocol.auth.exceptions.mfa import MFATokenError -from ldap_protocol.auth.schemas import MFACreateRequest, MFAGetResponse from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds diff --git a/app/api/auth/router_auth.py b/app/api/auth/router_auth.py index a5c98911f..bce48398a 100644 --- a/app/api/auth/router_auth.py +++ b/app/api/auth/router_auth.py @@ -13,6 +13,7 @@ from fastapi_error_map.rules import rule from api.auth.adapters import AuthFastAPIAdapter +from api.auth.schemas import OAuth2Form, SetupRequest from api.auth.utils import get_ip_from_request, get_user_agent_from_request from api.error_routing import ( ERROR_MAP_TYPE, @@ -27,11 +28,7 @@ MFARequiredError, MissingMFACredentialsError, ) -from ldap_protocol.auth.schemas import ( - MFAChallengeResponse, - OAuth2Form, - SetupRequest, -) +from ldap_protocol.auth.schemas import MFAChallengeResponse from ldap_protocol.dialogue import UserSchema from ldap_protocol.identity.exceptions import ( AlreadyConfiguredError, diff --git a/app/api/auth/router_mfa.py b/app/api/auth/router_mfa.py index 8e275b242..a003f7a52 100644 --- a/app/api/auth/router_mfa.py +++ b/app/api/auth/router_mfa.py @@ -14,6 +14,7 @@ from fastapi_error_map.rules import rule from api.auth.adapters import MFAFastAPIAdapter +from api.auth.schemas import MFACreateRequest, MFAGetResponse from api.auth.utils import ( get_ip_from_request, get_user_agent_from_request, @@ -35,7 +36,6 @@ NetworkPolicyError, NotFoundError, ) -from ldap_protocol.auth.schemas import MFACreateRequest, MFAGetResponse from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds translator = DomainErrorTranslator(DomainCodes.MFA) diff --git a/app/api/auth/schemas.py b/app/api/auth/schemas.py new file mode 100644 index 000000000..099da4589 --- /dev/null +++ b/app/api/auth/schemas.py @@ -0,0 +1,80 @@ +"""Auth schemas. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import re + +from fastapi.param_functions import Form +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, SecretStr, computed_field, field_validator + +from ldap_protocol.utils.const import EmailStr + +_domain_re = re.compile( + "^((?!-)[A-Za-z0-9-]" + "{1,63}(? str: # noqa + if re.match(_domain_re, v) is None: + raise ValueError("Invalid domain value") + return v.lower() + + +class MFACreateRequest(BaseModel): + """Create MFA creds request.""" + + mfa_key: str + mfa_secret: str + is_ldap_scope: bool + + @computed_field # type: ignore + @property + def key_name(self) -> str: + if self.is_ldap_scope: + return "mfa_key_ldap" + + return "mfa_key" + + @computed_field # type: ignore + @property + def secret_name(self) -> str: + if self.is_ldap_scope: + return "mfa_secret_ldap" + + return "mfa_secret" + + +class MFAGetResponse(BaseModel): + """Secret creds of api.""" + + mfa_key: str | None + mfa_secret: SecretStr | None + mfa_key_ldap: str | None + mfa_secret_ldap: SecretStr | None diff --git a/app/ldap_protocol/auth/auth_manager.py b/app/ldap_protocol/auth/auth_manager.py index d2e9073fd..d1fc1dacf 100644 --- a/app/ldap_protocol/auth/auth_manager.py +++ b/app/ldap_protocol/auth/auth_manager.py @@ -11,12 +11,13 @@ from starlette.datastructures import URL from abstract_service import AbstractService +from api.auth.schemas import OAuth2Form from config import Settings from entities import User from enums import AuthorizationRules, MFAFlags from ldap_protocol.auth.dto import SetupDTO from ldap_protocol.auth.mfa_manager import MFAManager -from ldap_protocol.auth.schemas import LoginDTO, OAuth2Form +from ldap_protocol.auth.schemas import LoginDTO from ldap_protocol.auth.use_cases import SetupUseCase from ldap_protocol.auth.utils import authenticate_user from ldap_protocol.dialogue import UserSchema diff --git a/app/ldap_protocol/auth/mfa_manager.py b/app/ldap_protocol/auth/mfa_manager.py index 334a66a44..b212588d2 100644 --- a/app/ldap_protocol/auth/mfa_manager.py +++ b/app/ldap_protocol/auth/mfa_manager.py @@ -18,6 +18,7 @@ from starlette.datastructures import URL from abstract_service import AbstractService +from api.auth.schemas import MFACreateRequest, MFAGetResponse from config import Settings from entities import CatalogueSetting, NetworkPolicy, User from enums import AuthorizationRules, MFAChallengeStatuses, MFAFlags @@ -31,11 +32,7 @@ MissingMFACredentialsError, NetworkPolicyError, ) -from ldap_protocol.auth.schemas import ( - MFAChallengeResponse, - MFACreateRequest, - MFAGetResponse, -) +from ldap_protocol.auth.schemas import MFAChallengeResponse from ldap_protocol.auth.utils import get_user from ldap_protocol.identity import IdentityProvider from ldap_protocol.multifactor import ( diff --git a/app/ldap_protocol/auth/schemas.py b/app/ldap_protocol/auth/schemas.py index fe786189c..631ef5385 100644 --- a/app/ldap_protocol/auth/schemas.py +++ b/app/ldap_protocol/auth/schemas.py @@ -4,91 +4,9 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -import re from dataclasses import dataclass -from datetime import datetime -from ipaddress import IPv4Address, IPv6Address -from typing import Literal -from fastapi.param_functions import Form -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import ( - BaseModel, - ConfigDict, - Field, - SecretStr, - computed_field, - field_validator, -) - -from ldap_protocol.utils.const import EmailStr - -_domain_re = re.compile( - "^((?!-)[A-Za-z0-9-]" + "{1,63}(? str: # noqa - if re.match(_domain_re, v) is None: - raise ValueError("Invalid domain value") - return v.lower() - - -class MFACreateRequest(BaseModel): - """Create MFA creds request.""" - - mfa_key: str - mfa_secret: str - is_ldap_scope: bool - - @computed_field # type: ignore - @property - def key_name(self) -> str: - if self.is_ldap_scope: - return "mfa_key_ldap" - - return "mfa_key" - - @computed_field # type: ignore - @property - def secret_name(self) -> str: - if self.is_ldap_scope: - return "mfa_secret_ldap" - - return "mfa_secret" - - -class MFAGetResponse(BaseModel): - """Secret creds of api.""" - - mfa_key: str | None - mfa_secret: SecretStr | None - mfa_key_ldap: str | None - mfa_secret_ldap: SecretStr | None +from pydantic import BaseModel class MFAChallengeResponse(BaseModel): @@ -104,15 +22,3 @@ class LoginDTO: session_key: str | None mfa_challenge: MFAChallengeResponse | None - - -class SessionContentSchema(BaseModel): - """Session content schema.""" - - model_config = ConfigDict(extra="allow") - - id: int - sign: str = Field("", description="Session signature") - issued: datetime - ip: IPv4Address | IPv6Address - protocol: Literal["ldap", "http"] = "http" diff --git a/app/ldap_protocol/policies/audit/dataclasses.py b/app/ldap_protocol/policies/audit/dataclasses.py index 093b92ff7..4c7d48ab8 100644 --- a/app/ldap_protocol/policies/audit/dataclasses.py +++ b/app/ldap_protocol/policies/audit/dataclasses.py @@ -14,6 +14,14 @@ from ldap_protocol.objects import OperationEvent +@dataclass +class OAuth2FormDTO: + """OAuth2 form data transfer object.""" + + username: str + password: str + + @dataclass class AuditPolicyTriggerDTO: """Audit policy trigger data transfer object.""" diff --git a/app/ldap_protocol/policies/audit/monitor.py b/app/ldap_protocol/policies/audit/monitor.py index 5ce08d0ab..59e40e8bb 100644 --- a/app/ldap_protocol/policies/audit/monitor.py +++ b/app/ldap_protocol/policies/audit/monitor.py @@ -22,7 +22,6 @@ MFATokenError, NetworkPolicyError, ) -from ldap_protocol.auth.schemas import OAuth2Form from ldap_protocol.identity.exceptions import ( AuthorizationError, AuthValidationError, @@ -35,6 +34,7 @@ from ldap_protocol.multifactor import MFA_HTTP_Creds from ldap_protocol.objects import OperationEvent from ldap_protocol.policies.audit.audit_use_case import AuditUseCase +from ldap_protocol.policies.audit.dataclasses import OAuth2FormDTO from ldap_protocol.policies.audit.events.factory import ( RawAuditEventBuilderRedis, ) @@ -224,7 +224,7 @@ async def wrapped_proxy_request( def wrap_login(self, attr: _T) -> _T: @wraps(attr) async def wrapped_login( - form: OAuth2Form, + form: OAuth2FormDTO, url: URL, ip: IPv4Address | IPv6Address, user_agent: str, From 8e9aeb4fb6483af9c810af58707d7271c17d7d2c Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Mon, 2 Mar 2026 12:54:27 +0300 Subject: [PATCH 2/7] refactor: reorganize imports and update schema definitions in audit and auth modules --- app/api/audit/adapter.py | 12 ++--- app/api/audit/router.py | 12 ++--- .../policies => api}/audit/schemas.py | 10 ++-- app/api/auth/adapters/auth.py | 3 +- app/api/auth/router_auth.py | 3 +- app/api/auth/schemas.py | 14 ++++++ app/api/dhcp/schemas.py | 5 ++ app/ldap_protocol/auth/auth_manager.py | 12 +++-- app/ldap_protocol/auth/mfa_manager.py | 7 ++- app/ldap_protocol/auth/schemas.py | 24 ---------- .../kerberos/{schemas.py => dtos.py} | 10 ++-- app/ldap_protocol/kerberos/service.py | 46 +++++++++++-------- app/ldap_protocol/kerberos/template_render.py | 10 ++-- interface | 2 +- tests/test_api/test_audit/test_router.py | 8 ++-- 15 files changed, 90 insertions(+), 88 deletions(-) rename app/{ldap_protocol/policies => api}/audit/schemas.py (86%) create mode 100644 app/api/dhcp/schemas.py delete mode 100644 app/ldap_protocol/auth/schemas.py rename app/ldap_protocol/kerberos/{schemas.py => dtos.py} (85%) diff --git a/app/api/audit/adapter.py b/app/api/audit/adapter.py index e7a39665d..7196fe002 100644 --- a/app/api/audit/adapter.py +++ b/app/api/audit/adapter.py @@ -4,17 +4,17 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from api.base_adapter import BaseAdapter -from ldap_protocol.policies.audit.dataclasses import ( - AuditDestinationDTO, - AuditPolicyDTO, -) -from ldap_protocol.policies.audit.schemas import ( +from api.audit.schemas import ( AuditDestinationResponse, AuditDestinationSchemaRequest, AuditPolicyResponse, AuditPolicySchemaRequest, ) +from api.base_adapter import BaseAdapter +from ldap_protocol.policies.audit.dataclasses import ( + AuditDestinationDTO, + AuditPolicyDTO, +) from ldap_protocol.policies.audit.service import AuditService diff --git a/app/api/audit/router.py b/app/api/audit/router.py index 6209d0740..24ccbe3c9 100644 --- a/app/api/audit/router.py +++ b/app/api/audit/router.py @@ -9,6 +9,12 @@ from fastapi_error_map.routing import ErrorAwareRouter from fastapi_error_map.rules import rule +from api.audit.schemas import ( + AuditDestinationResponse, + AuditDestinationSchemaRequest, + AuditPolicyResponse, + AuditPolicySchemaRequest, +) from api.auth.utils import verify_auth from api.error_routing import ( ERROR_MAP_TYPE, @@ -21,12 +27,6 @@ AuditAlreadyExistsError, AuditNotFoundError, ) -from ldap_protocol.policies.audit.schemas import ( - AuditDestinationResponse, - AuditDestinationSchemaRequest, - AuditPolicyResponse, - AuditPolicySchemaRequest, -) from .adapter import AuditPoliciesAdapter diff --git a/app/ldap_protocol/policies/audit/schemas.py b/app/api/audit/schemas.py similarity index 86% rename from app/ldap_protocol/policies/audit/schemas.py rename to app/api/audit/schemas.py index a23387467..40bbf52e9 100644 --- a/app/ldap_protocol/policies/audit/schemas.py +++ b/app/api/audit/schemas.py @@ -1,11 +1,9 @@ -"""Audit policies schemas module. +"""Audit schemas. Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from dataclasses import dataclass - from pydantic import BaseModel, Field from enums import AuditDestinationProtocolType, AuditDestinationServiceType @@ -20,8 +18,7 @@ class AuditPolicySchemaRequest(BaseModel): severity: str -@dataclass -class AuditPolicyResponse: +class AuditPolicyResponse(BaseModel): """Audit policy schema.""" id: int @@ -44,8 +41,7 @@ class Config: # noqa: D106 use_enum_values = True -@dataclass -class AuditDestinationResponse: +class AuditDestinationResponse(BaseModel): """Audit destination schema.""" id: int diff --git a/app/api/auth/adapters/auth.py b/app/api/auth/adapters/auth.py index e1f9f3621..a5f9a9aae 100644 --- a/app/api/auth/adapters/auth.py +++ b/app/api/auth/adapters/auth.py @@ -9,11 +9,10 @@ from adaptix.conversion import get_converter from fastapi import Request -from api.auth.schemas import OAuth2Form, SetupRequest +from api.auth.schemas import MFAChallengeResponse, OAuth2Form, SetupRequest from api.base_adapter import BaseAdapter from ldap_protocol.auth import AuthManager from ldap_protocol.auth.dto import SetupDTO -from ldap_protocol.auth.schemas import MFAChallengeResponse from ldap_protocol.dialogue import UserSchema _convert_request_to_dto = get_converter(SetupRequest, SetupDTO) diff --git a/app/api/auth/router_auth.py b/app/api/auth/router_auth.py index bce48398a..004f8c0c0 100644 --- a/app/api/auth/router_auth.py +++ b/app/api/auth/router_auth.py @@ -13,7 +13,7 @@ from fastapi_error_map.rules import rule from api.auth.adapters import AuthFastAPIAdapter -from api.auth.schemas import OAuth2Form, SetupRequest +from api.auth.schemas import MFAChallengeResponse, OAuth2Form, SetupRequest from api.auth.utils import get_ip_from_request, get_user_agent_from_request from api.error_routing import ( ERROR_MAP_TYPE, @@ -28,7 +28,6 @@ MFARequiredError, MissingMFACredentialsError, ) -from ldap_protocol.auth.schemas import MFAChallengeResponse from ldap_protocol.dialogue import UserSchema from ldap_protocol.identity.exceptions import ( AlreadyConfiguredError, diff --git a/app/api/auth/schemas.py b/app/api/auth/schemas.py index 099da4589..5473e4184 100644 --- a/app/api/auth/schemas.py +++ b/app/api/auth/schemas.py @@ -78,3 +78,17 @@ class MFAGetResponse(BaseModel): mfa_secret: SecretStr | None mfa_key_ldap: str | None mfa_secret_ldap: SecretStr | None + + +class MFAChallengeResponse(BaseModel): + """MFA Challenge state.""" + + status: str + message: str + + +class LoginResponse(BaseModel): + """Login response.""" + + session_key: str | None + mfa_challenge: MFAChallengeResponse | None diff --git a/app/api/dhcp/schemas.py b/app/api/dhcp/schemas.py new file mode 100644 index 000000000..ee79e1669 --- /dev/null +++ b/app/api/dhcp/schemas.py @@ -0,0 +1,5 @@ +"""DHCP schemas. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" diff --git a/app/ldap_protocol/auth/auth_manager.py b/app/ldap_protocol/auth/auth_manager.py index d1fc1dacf..605c2d607 100644 --- a/app/ldap_protocol/auth/auth_manager.py +++ b/app/ldap_protocol/auth/auth_manager.py @@ -11,13 +11,12 @@ from starlette.datastructures import URL from abstract_service import AbstractService -from api.auth.schemas import OAuth2Form +from api.auth.schemas import LoginResponse, OAuth2Form from config import Settings from entities import User from enums import AuthorizationRules, MFAFlags from ldap_protocol.auth.dto import SetupDTO from ldap_protocol.auth.mfa_manager import MFAManager -from ldap_protocol.auth.schemas import LoginDTO from ldap_protocol.auth.use_cases import SetupUseCase from ldap_protocol.auth.utils import authenticate_user from ldap_protocol.dialogue import UserSchema @@ -105,7 +104,7 @@ async def login( url: URL, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> LoginDTO: + ) -> LoginResponse: """Log in a user. :param form: OAuth2Form with username and password @@ -179,7 +178,10 @@ async def login( ip=ip, user_agent=user_agent, ) - return LoginDTO(key, mfa_challenge) + return LoginResponse( + session_key=key, + mfa_challenge=mfa_challenge, + ) session_key = await self._repository.create_session_key( user, @@ -187,7 +189,7 @@ async def login( user_agent, self.key_ttl, ) - return LoginDTO(session_key, None) + return LoginResponse(session_key=session_key, mfa_challenge=None) async def _update_password( self, diff --git a/app/ldap_protocol/auth/mfa_manager.py b/app/ldap_protocol/auth/mfa_manager.py index b212588d2..9c20097a8 100644 --- a/app/ldap_protocol/auth/mfa_manager.py +++ b/app/ldap_protocol/auth/mfa_manager.py @@ -18,7 +18,11 @@ from starlette.datastructures import URL from abstract_service import AbstractService -from api.auth.schemas import MFACreateRequest, MFAGetResponse +from api.auth.schemas import ( + MFAChallengeResponse, + MFACreateRequest, + MFAGetResponse, +) from config import Settings from entities import CatalogueSetting, NetworkPolicy, User from enums import AuthorizationRules, MFAChallengeStatuses, MFAFlags @@ -32,7 +36,6 @@ MissingMFACredentialsError, NetworkPolicyError, ) -from ldap_protocol.auth.schemas import MFAChallengeResponse from ldap_protocol.auth.utils import get_user from ldap_protocol.identity import IdentityProvider from ldap_protocol.multifactor import ( diff --git a/app/ldap_protocol/auth/schemas.py b/app/ldap_protocol/auth/schemas.py deleted file mode 100644 index 631ef5385..000000000 --- a/app/ldap_protocol/auth/schemas.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Schemas for auth module. - -Copyright (c) 2025 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -from dataclasses import dataclass - -from pydantic import BaseModel - - -class MFAChallengeResponse(BaseModel): - """MFA Challenge state.""" - - status: str - message: str - - -@dataclass -class LoginDTO: - """Login Data Transfer Object.""" - - session_key: str | None - mfa_challenge: MFAChallengeResponse | None diff --git a/app/ldap_protocol/kerberos/schemas.py b/app/ldap_protocol/kerberos/dtos.py similarity index 85% rename from app/ldap_protocol/kerberos/schemas.py rename to app/ldap_protocol/kerberos/dtos.py index b3be3abb5..d01775aee 100644 --- a/app/ldap_protocol/kerberos/schemas.py +++ b/app/ldap_protocol/kerberos/dtos.py @@ -11,7 +11,7 @@ @dataclass -class KerberosAdminDnGroup: +class KerberosAdminDnGroupDTO: """Kerberos admin, services container, and admin group DNs.""" krbadmin_dn: str @@ -20,8 +20,8 @@ class KerberosAdminDnGroup: @dataclass -class AddRequests: - """AddRequests for Kerberos admin structure: group, services, krb_user.""" +class AddRequestsDTO: + """AddRequestsDTO for Kerberos admin structure.""" group: AddRequest services: AddRequest @@ -29,7 +29,7 @@ class AddRequests: @dataclass -class KDCContext: +class KDCContextDTO: """Kerberos KDC configuration context.""" base_dn: str @@ -43,7 +43,7 @@ class KDCContext: @dataclass -class TaskStruct: +class TaskStructDTO: """Structure for background task: function, args, kwargs.""" func: Callable[..., Any] diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index ec630f778..fa838abb9 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -30,6 +30,12 @@ from password_utils import PasswordUtils from .base import AbstractKadmin +from .dtos import ( + AddRequestsDTO, + KDCContextDTO, + KerberosAdminDnGroupDTO, + TaskStructDTO, +) from .exceptions import ( KRBAPIAddPrincipalError, KRBAPIConnectionError, @@ -42,7 +48,6 @@ KRBAPIStatusNotFoundError, ) from .ldap_structure import KRBLDAPStructureManager -from .schemas import AddRequests, KDCContext, KerberosAdminDnGroup, TaskStruct from .template_render import KRBTemplateRenderer from .utils import ( KerberosState, @@ -138,17 +143,20 @@ async def _get_base_dn(self) -> tuple[str, str]: ) return base_dn_list[0].path_dn, base_dn_list[0].name - def _build_kerberos_admin_dns(self, base_dn: str) -> KerberosAdminDnGroup: + def _build_kerberos_admin_dns( + self, + base_dn: str, + ) -> KerberosAdminDnGroupDTO: """Build DN strings for Kerberos admin, services, and group. :param str base_dn: Base DN. - :return KerberosAdminDnGroup: + :return KerberosAdminDnGroupDTO: dataclass with DN for krbadmin, services_container, krbadmin_group. """ krbadmin = f"cn=krbadmin,cn=Users,{base_dn}" services_container = get_system_container_dn(base_dn) krbgroup = f"cn=krbadmin,cn=Groups,{base_dn}" - return KerberosAdminDnGroup( + return KerberosAdminDnGroupDTO( krbadmin_dn=krbadmin, services_container_dn=services_container, krbadmin_group_dn=krbgroup, @@ -156,17 +164,17 @@ def _build_kerberos_admin_dns(self, base_dn: str) -> KerberosAdminDnGroup: def _build_add_requests( self, - dns: KerberosAdminDnGroup, + dns: KerberosAdminDnGroupDTO, mail: str, krbadmin_password: SecretStr, - ) -> AddRequests: + ) -> AddRequestsDTO: """Build AddRequest objects for group, services, and admin user. - :param KerberosAdminDnGroup dns: + :param KerberosAdminDnGroupDTO dns: DNs for krbadmin, services container, and group. :param str mail: Email for krbadmin. :param SecretStr krbadmin_password: Password for krbadmin. - :return AddRequests: + :return AddRequestsDTO: dataclass of AddRequest for group, services, and user. """ group = AddRequest.from_dict( @@ -219,7 +227,7 @@ def _build_add_requests( }, is_system=True, ) - return AddRequests( + return AddRequestsDTO( group=group, services=services, krb_user=krb_user, @@ -232,8 +240,8 @@ async def setup_kdc( stash_password: str, user: UserSchema, request: Request, - ) -> TaskStruct: - """Set up KDC, generate configs, and return TaskStruct. + ) -> TaskStructDTO: + """Set up KDC, generate configs, and return TaskStructDTO. Args: krbadmin_password (str): Password for krbadmin. @@ -289,17 +297,17 @@ async def setup_kdc( admin_password, ) - async def _get_kdc_context(self) -> KDCContext: + async def _get_kdc_context(self) -> KDCContextDTO: """Build and return context for KDC setup/config rendering. :raises Exception: If base DN cannot be retrieved. - :return KDCContext: dataclass with all required KDC context fields. + :return KDCContextDTO: dataclass with all required KDC context fields. """ base_dn, domain = await self._get_base_dn() krbadmin = f"cn=krbadmin,cn=users,{base_dn}" krbgroup = f"cn=krbadmin,cn=groups,{base_dn}" services_container = get_system_container_dn(base_dn) - return KDCContext( + return KDCContextDTO( base_dn=base_dn, domain=domain, krbadmin=krbadmin, @@ -335,7 +343,7 @@ async def _schedule_principal_task( request: Request, user: UserSchema, password: str, - ) -> TaskStruct: + ) -> TaskStructDTO: """Schedule background task for principal creation after KDC setup. :param Request request: FastAPI request (for DI container). @@ -356,7 +364,7 @@ async def _schedule_principal_task( user.user_principal_name.split("@")[0], password, ) - return TaskStruct(func=func, args=args) + return TaskStructDTO(func=func, args=args) async def add_principal( self, @@ -428,8 +436,8 @@ async def ktadd( self, names: list[str], is_rand_key: bool, - ) -> tuple[AsyncIterator[bytes], TaskStruct]: - """Generate keytab and return (aiter_bytes, TaskStruct). + ) -> tuple[AsyncIterator[bytes], TaskStructDTO]: + """Generate keytab and return (aiter_bytes, TaskStructDTO). :param list[str] names: List of principal names. :param bool is_rand_key: If True, generate new principal keys. @@ -442,7 +450,7 @@ async def ktadd( raise KerberosNotFoundError("Principal not found") aiter_bytes = response.aiter_bytes() func = response.aclose - return aiter_bytes, TaskStruct(func=func) + return aiter_bytes, TaskStructDTO(func=func) async def get_status(self) -> KerberosState: """Get Kerberos server state (db + actual server). diff --git a/app/ldap_protocol/kerberos/template_render.py b/app/ldap_protocol/kerberos/template_render.py index 0df7b36a7..d4428a596 100644 --- a/app/ldap_protocol/kerberos/template_render.py +++ b/app/ldap_protocol/kerberos/template_render.py @@ -6,7 +6,7 @@ import jinja2 -from .schemas import KDCContext +from .dtos import KDCContextDTO class KRBTemplateRenderer: @@ -23,11 +23,11 @@ def __init__(self, templates: jinja2.Environment) -> None: """ self._templates = templates - async def render_krb5(self, context: KDCContext) -> str: + async def render_krb5(self, context: KDCContextDTO) -> str: """Render the krb5.conf configuration file using the provided context. :param context: - KDCContext dataclass with Kerberos configuration parameters. + KDCContextDTO dataclass with Kerberos configuration parameters. :return: Rendered krb5.conf as a string. """ krb5_template = self._templates.get_template("krb5.conf") @@ -40,11 +40,11 @@ async def render_krb5(self, context: KDCContext) -> str: sync_password_url=context.sync_password_url, ) - async def render_kdc(self, context: KDCContext) -> str: + async def render_kdc(self, context: KDCContextDTO) -> str: """Render the kdc.conf configuration file using the provided context. :param context: - KDCContext dataclass with Kerberos configuration parameters. + KDCContextDTO dataclass with Kerberos configuration parameters. :return: Rendered kdc.conf as a string. """ kdc_template = self._templates.get_template("kdc.conf") diff --git a/interface b/interface index e1ca5656a..3732b6958 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 +Subproject commit 3732b695844e95e1692ae83e1b2e1de70e68b380 diff --git a/tests/test_api/test_audit/test_router.py b/tests/test_api/test_audit/test_router.py index 2abc682b6..bd201d23f 100644 --- a/tests/test_api/test_audit/test_router.py +++ b/tests/test_api/test_audit/test_router.py @@ -10,15 +10,15 @@ from fastapi import status from httpx import AsyncClient +from api.audit.schemas import ( + AuditDestinationSchemaRequest, + AuditPolicySchemaRequest, +) from enums import AuditDestinationProtocolType, AuditDestinationServiceType from ldap_protocol.policies.audit.dataclasses import ( AuditDestinationDTO, AuditPolicyDTO, ) -from ldap_protocol.policies.audit.schemas import ( - AuditDestinationSchemaRequest, - AuditPolicySchemaRequest, -) @pytest.mark.asyncio From 5fb42bb3737d69ad916c0f3a972cfa9ed18aa136 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Mon, 2 Mar 2026 13:06:05 +0300 Subject: [PATCH 3/7] refactor: reorganize and update DHCP schemas and imports across modules --- app/api/dhcp/adapter.py | 4 +- app/api/dhcp/router.py | 22 ++--- app/api/dhcp/schemas.py | 114 +++++++++++++++++++++++ app/ldap_protocol/dhcp/__init__.py | 20 ---- app/ldap_protocol/dhcp/schemas.py | 113 +--------------------- tests/test_api/test_dhcp/test_adapter.py | 10 +- 6 files changed, 133 insertions(+), 150 deletions(-) diff --git a/app/api/dhcp/adapter.py b/app/api/dhcp/adapter.py index d063ad144..2a680e137 100644 --- a/app/api/dhcp/adapter.py +++ b/app/api/dhcp/adapter.py @@ -7,8 +7,7 @@ from ipaddress import IPv4Address from api.base_adapter import BaseAdapter -from ldap_protocol.dhcp import ( - AbstractDHCPManager, +from api.dhcp.schemas import ( DHCPChangeStateSchemaRequest, DHCPLeaseSchemaRequest, DHCPLeaseSchemaResponse, @@ -19,6 +18,7 @@ DHCPSubnetSchemaAddRequest, DHCPSubnetSchemaResponse, ) +from ldap_protocol.dhcp import AbstractDHCPManager from ldap_protocol.dhcp.dataclasses import ( DHCPLease, DHCPOptionData, diff --git a/app/api/dhcp/router.py b/app/api/dhcp/router.py index a41e806a0..d1eb9b77b 100644 --- a/app/api/dhcp/router.py +++ b/app/api/dhcp/router.py @@ -12,6 +12,17 @@ from fastapi_error_map.rules import rule from api.auth.utils import verify_auth +from api.dhcp.schemas import ( + DHCPChangeStateSchemaRequest, + DHCPLeaseSchemaRequest, + DHCPLeaseSchemaResponse, + DHCPLeaseToReservationErrorResponse, + DHCPReservationSchemaRequest, + DHCPReservationSchemaResponse, + DHCPStateSchemaResponse, + DHCPSubnetSchemaAddRequest, + DHCPSubnetSchemaResponse, +) from api.error_routing import ( ERROR_MAP_TYPE, DishkaErrorAwareRoute, @@ -27,17 +38,6 @@ DHCPOperationError, DHCPValidationError, ) -from ldap_protocol.dhcp.schemas import ( - DHCPChangeStateSchemaRequest, - DHCPLeaseSchemaRequest, - DHCPLeaseSchemaResponse, - DHCPLeaseToReservationErrorResponse, - DHCPReservationSchemaRequest, - DHCPReservationSchemaResponse, - DHCPStateSchemaResponse, - DHCPSubnetSchemaAddRequest, - DHCPSubnetSchemaResponse, -) from .adapter import DHCPAdapter diff --git a/app/api/dhcp/schemas.py b/app/api/dhcp/schemas.py index ee79e1669..c3e10dde8 100644 --- a/app/api/dhcp/schemas.py +++ b/app/api/dhcp/schemas.py @@ -3,3 +3,117 @@ Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ + +from datetime import datetime +from ipaddress import IPv4Address, IPv4Network + +from pydantic import BaseModel, field_serializer + +from ldap_protocol.dhcp.enums import DHCPManagerState + + +class DHCPSubnetSchemaAddRequest(BaseModel): + """Schema for creating a new DHCP subnet.""" + + subnet: IPv4Network + pool: IPv4Network | str + valid_lifetime: int | None = None + default_gateway: IPv4Address | None = None + + @field_serializer("subnet") + def serialize_subnet(self, subnet: IPv4Network) -> str: + return str(subnet) + + @field_serializer("pool") + def serialize_pool(self, pool: IPv4Network | str) -> str: + return str(pool) + + @field_serializer("default_gateway") + def serialize_default_gateway( + self, + gateway: IPv4Address | None, + ) -> str | None: + return str(gateway) if gateway else None + + +class DHCPSubnetSchemaResponse(BaseModel): + """Schema for responding with DHCP subnet information.""" + + id: int + subnet: IPv4Network + pool: list[IPv4Network | str] + valid_lifetime: int | None = None + default_gateway: IPv4Address | None = None + + @field_serializer("subnet") + def serialize_subnet(self, subnet: IPv4Network) -> str: + return str(subnet) + + @field_serializer("pool") + def serialize_pool(self, pool: list[IPv4Network | str]) -> list[str]: + return [str(p) for p in pool] + + @field_serializer("default_gateway") + def serialize_default_gateway( + self, + gateway: IPv4Address | None, + ) -> str | None: + return str(gateway) if gateway else None + + +class DHCPLeaseSchemaRequest(BaseModel): + """Schema for creating a new DHCP lease.""" + + subnet_id: int + ip_address: IPv4Address + mac_address: str + hostname: str | None = None + valid_lifetime: int | None = None + + +class DHCPLeaseSchemaResponse(BaseModel): + """Schema for responding with DHCP lease information.""" + + subnet_id: int + ip_address: IPv4Address + mac_address: str + hostname: str | None = None + expires: datetime | None = None + + +class DHCPReservationSchemaRequest(BaseModel): + """Schema for creating a new DHCP reservation.""" + + subnet_id: int + ip_address: IPv4Address + mac_address: str + hostname: str | None = None + + +class DHCPReservationSchemaResponse(BaseModel): + """Schema for responding with DHCP reservation information.""" + + subnet_id: int + ip_address: IPv4Address + mac_address: str + hostname: str | None = None + + +class DHCPLeaseToReservationErrorResponse(BaseModel): + """Schema for responding with lease to reservation operation error.""" + + text: str + ip_address: IPv4Address | None = None + mac_address: str | None = None + + +class DHCPChangeStateSchemaRequest(BaseModel): + """Schema for setting up the DHCP server.""" + + dhcp_manager_state: DHCPManagerState + + +class DHCPStateSchemaResponse(BaseModel): + """Schema for responding with DHCP server state.""" + + dhcp_manager_state: DHCPManagerState diff --git a/app/ldap_protocol/dhcp/__init__.py b/app/ldap_protocol/dhcp/__init__.py index d9f4c277c..a813b3d03 100644 --- a/app/ldap_protocol/dhcp/__init__.py +++ b/app/ldap_protocol/dhcp/__init__.py @@ -12,17 +12,6 @@ ) from .kea_dhcp_manager import KeaDHCPManager from .kea_dhcp_repository import KeaDHCPAPIRepository -from .schemas import ( - DHCPChangeStateSchemaRequest, - DHCPLeaseSchemaRequest, - DHCPLeaseSchemaResponse, - DHCPLeaseToReservationErrorResponse, - DHCPReservationSchemaRequest, - DHCPReservationSchemaResponse, - DHCPStateSchemaResponse, - DHCPSubnetSchemaAddRequest, - DHCPSubnetSchemaResponse, -) from .stub import StubDHCPAPIRepository, StubDHCPManager @@ -58,13 +47,4 @@ def get_dhcp_api_repository_class( "DHCPOperationError", "DHCPAPIError", "DHCPSubnetSchemaRequest", - "DHCPSubnetSchemaAddRequest", - "DHCPReservationSchemaRequest", - "DHCPSubnetSchemaResponse", - "DHCPLeaseSchemaRequest", - "DHCPLeaseSchemaResponse", - "DHCPReservationSchemaResponse", - "DHCPChangeStateSchemaRequest", - "DHCPStateSchemaResponse", - "DHCPLeaseToReservationErrorResponse", ] diff --git a/app/ldap_protocol/dhcp/schemas.py b/app/ldap_protocol/dhcp/schemas.py index 8f3b0a2c6..f5cad3041 100644 --- a/app/ldap_protocol/dhcp/schemas.py +++ b/app/ldap_protocol/dhcp/schemas.py @@ -5,13 +5,9 @@ """ from dataclasses import dataclass, field -from datetime import datetime -from ipaddress import IPv4Address, IPv4Network - -from pydantic import BaseModel, field_serializer from .dataclasses import DHCPLease, DHCPReservation, DHCPSubnet -from .enums import DHCPManagerState, KeaDHCPCommands +from .enums import KeaDHCPCommands @dataclass @@ -51,110 +47,3 @@ class KeaDHCPAPIReservationRequest(KeaDHCPCommandRequest): arguments: DHCPReservation service: list[str] = field(default_factory=lambda: ["dhcp4"]) - - -class DHCPSubnetSchemaAddRequest(BaseModel): - """Schema for creating a new DHCP subnet.""" - - subnet: IPv4Network - pool: IPv4Network | str - valid_lifetime: int | None = None - default_gateway: IPv4Address | None = None - - @field_serializer("subnet") - def serialize_subnet(self, subnet: IPv4Network) -> str: - return str(subnet) - - @field_serializer("pool") - def serialize_pool(self, pool: IPv4Network | str) -> str: - return str(pool) - - @field_serializer("default_gateway") - def serialize_default_gateway( - self, - gateway: IPv4Address | None, - ) -> str | None: - return str(gateway) if gateway else None - - -class DHCPSubnetSchemaResponse(BaseModel): - """Schema for responding with DHCP subnet information.""" - - id: int - subnet: IPv4Network - pool: list[IPv4Network | str] - valid_lifetime: int | None = None - default_gateway: IPv4Address | None = None - - @field_serializer("subnet") - def serialize_subnet(self, subnet: IPv4Network) -> str: - return str(subnet) - - @field_serializer("pool") - def serialize_pool(self, pool: list[IPv4Network | str]) -> list[str]: - return [str(p) for p in pool] - - @field_serializer("default_gateway") - def serialize_default_gateway( - self, - gateway: IPv4Address | None, - ) -> str | None: - return str(gateway) if gateway else None - - -class DHCPLeaseSchemaRequest(BaseModel): - """Schema for creating a new DHCP lease.""" - - subnet_id: int - ip_address: IPv4Address - mac_address: str - hostname: str | None = None - valid_lifetime: int | None = None - - -class DHCPLeaseSchemaResponse(BaseModel): - """Schema for responding with DHCP lease information.""" - - subnet_id: int - ip_address: IPv4Address - mac_address: str - hostname: str | None = None - expires: datetime | None = None - - -class DHCPReservationSchemaRequest(BaseModel): - """Schema for creating a new DHCP reservation.""" - - subnet_id: int - ip_address: IPv4Address - mac_address: str - hostname: str | None = None - - -class DHCPReservationSchemaResponse(BaseModel): - """Schema for responding with DHCP reservation information.""" - - subnet_id: int - ip_address: IPv4Address - mac_address: str - hostname: str | None = None - - -class DHCPLeaseToReservationErrorResponse(BaseModel): - """Schema for responding with lease to reservation operation error.""" - - text: str - ip_address: IPv4Address | None = None - mac_address: str | None = None - - -class DHCPChangeStateSchemaRequest(BaseModel): - """Schema for setting up the DHCP server.""" - - dhcp_manager_state: DHCPManagerState - - -class DHCPStateSchemaResponse(BaseModel): - """Schema for responding with DHCP server state.""" - - dhcp_manager_state: DHCPManagerState diff --git a/tests/test_api/test_dhcp/test_adapter.py b/tests/test_api/test_dhcp/test_adapter.py index 5d2dd4b26..f67b03016 100644 --- a/tests/test_api/test_dhcp/test_adapter.py +++ b/tests/test_api/test_dhcp/test_adapter.py @@ -10,6 +10,11 @@ import pytest from api.dhcp.adapter import DHCPAdapter +from api.dhcp.schemas import ( + DHCPLeaseSchemaRequest, + DHCPReservationSchemaRequest, + DHCPSubnetSchemaAddRequest, +) from authorization_provider_protocol import AuthorizationProviderProtocol from ldap_protocol.dhcp.dataclasses import ( DHCPLease, @@ -18,11 +23,6 @@ DHCPReservation, DHCPSubnet, ) -from ldap_protocol.dhcp.schemas import ( - DHCPLeaseSchemaRequest, - DHCPReservationSchemaRequest, - DHCPSubnetSchemaAddRequest, -) @pytest.fixture From f0f80b9bcae7f017aa2b0ff87e0285c395741741 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Mon, 2 Mar 2026 13:14:13 +0300 Subject: [PATCH 4/7] refactor: remove unused DHCP schemas and update import paths in related modules --- app/api/audit/adapter.py | 2 +- app/ldap_protocol/dhcp/{schemas.py => dtos.py} | 0 app/ldap_protocol/dhcp/kea_dhcp_repository.py | 12 ++++++------ app/ldap_protocol/dhcp/retorts.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename app/ldap_protocol/dhcp/{schemas.py => dtos.py} (100%) diff --git a/app/api/audit/adapter.py b/app/api/audit/adapter.py index 7196fe002..9d438beb5 100644 --- a/app/api/audit/adapter.py +++ b/app/api/audit/adapter.py @@ -49,7 +49,7 @@ async def get_destinations(self) -> list[AuditDestinationResponse]: """Get all audit destinations.""" return [ AuditDestinationResponse( - id=destination.id, # type: ignore + id=destination.id, name=destination.name, service_type=destination.service_type.name.lower(), host=destination.host, diff --git a/app/ldap_protocol/dhcp/schemas.py b/app/ldap_protocol/dhcp/dtos.py similarity index 100% rename from app/ldap_protocol/dhcp/schemas.py rename to app/ldap_protocol/dhcp/dtos.py diff --git a/app/ldap_protocol/dhcp/kea_dhcp_repository.py b/app/ldap_protocol/dhcp/kea_dhcp_repository.py index a41c8e82c..849523bec 100644 --- a/app/ldap_protocol/dhcp/kea_dhcp_repository.py +++ b/app/ldap_protocol/dhcp/kea_dhcp_repository.py @@ -17,6 +17,12 @@ DHCPReservation, DHCPSubnet, ) +from .dtos import ( + KeaDHCPAPILeaseRequest, + KeaDHCPAPIReservationRequest, + KeaDHCPAPISubnetRequest, + KeaDHCPBaseAPIRequest, +) from .enums import KeaDHCPCommands, KeaDHCPResultCodes from .exceptions import ( DHCPAPIError, @@ -40,12 +46,6 @@ release_lease_retort, update_subnet_retort, ) -from .schemas import ( - KeaDHCPAPILeaseRequest, - KeaDHCPAPIReservationRequest, - KeaDHCPAPISubnetRequest, - KeaDHCPBaseAPIRequest, -) class KeaDHCPAPIRepository(DHCPAPIRepository): diff --git a/app/ldap_protocol/dhcp/retorts.py b/app/ldap_protocol/dhcp/retorts.py index 2a0d4c11d..023b800a1 100644 --- a/app/ldap_protocol/dhcp/retorts.py +++ b/app/ldap_protocol/dhcp/retorts.py @@ -7,7 +7,7 @@ from adaptix import Retort, name_mapping from .dataclasses import DHCPLease, DHCPReservation, DHCPSubnet -from .schemas import ( +from .dtos import ( KeaDHCPAPILeaseRequest, KeaDHCPAPISubnetRequest, KeaDHCPBaseAPIRequest, From 1d23ef44bf6067b39345c3986faa95cc2ca94583 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Mon, 2 Mar 2026 15:49:31 +0300 Subject: [PATCH 5/7] refactor: update authentication and MFA schemas to use DTOs and improve response handling --- app/api/auth/adapters/auth.py | 13 +++-- app/api/auth/adapters/mfa.py | 22 +++++++- app/api/auth/schemas.py | 11 +--- app/ldap_protocol/auth/auth_manager.py | 23 ++++---- app/ldap_protocol/auth/dto.py | 54 +++++++++++++++++++ app/ldap_protocol/auth/mfa_manager.py | 32 +++++------ app/ldap_protocol/dhcp/dtos.py | 2 +- .../policies/audit/dataclasses.py | 8 --- app/ldap_protocol/policies/audit/monitor.py | 4 +- 9 files changed, 117 insertions(+), 52 deletions(-) diff --git a/app/api/auth/adapters/auth.py b/app/api/auth/adapters/auth.py index a5f9a9aae..ef6a64e1b 100644 --- a/app/api/auth/adapters/auth.py +++ b/app/api/auth/adapters/auth.py @@ -12,7 +12,7 @@ from api.auth.schemas import MFAChallengeResponse, OAuth2Form, SetupRequest from api.base_adapter import BaseAdapter from ldap_protocol.auth import AuthManager -from ldap_protocol.auth.dto import SetupDTO +from ldap_protocol.auth.dto import LoginRequestDTO, SetupDTO from ldap_protocol.dialogue import UserSchema _convert_request_to_dto = get_converter(SetupRequest, SetupDTO) @@ -41,7 +41,10 @@ async def login( :return: None """ login_dto = await self._service.login( - form=form, + form=LoginRequestDTO( + username=form.username, + password=form.password, + ), url=request.url_for("callback_mfa"), ip=ip, user_agent=user_agent, @@ -50,7 +53,11 @@ async def login( self._service.set_new_session_key( login_dto.session_key, ) - return login_dto.mfa_challenge + if login_dto.mfa_challenge is not None: + return MFAChallengeResponse( + status=login_dto.mfa_challenge.status, + message=login_dto.mfa_challenge.message, + ) async def reset_password( self, diff --git a/app/api/auth/adapters/mfa.py b/app/api/auth/adapters/mfa.py index 5183cb782..163858ba6 100644 --- a/app/api/auth/adapters/mfa.py +++ b/app/api/auth/adapters/mfa.py @@ -12,6 +12,7 @@ from api.auth.schemas import MFACreateRequest, MFAGetResponse from api.base_adapter import BaseAdapter from ldap_protocol.auth import MFAManager +from ldap_protocol.auth.dto import MFACreateRequestDTO from ldap_protocol.auth.exceptions.mfa import MFATokenError from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds @@ -25,7 +26,15 @@ async def setup_mfa(self, mfa: MFACreateRequest) -> bool: :param mfa: MFACreateRequest :return: bool """ - return await self._service.setup_mfa(mfa) + return await self._service.setup_mfa( + MFACreateRequestDTO( + mfa_key=mfa.mfa_key, + mfa_secret=mfa.mfa_secret, + is_ldap_scope=mfa.is_ldap_scope, + key_name=mfa.key_name, + secret_name=mfa.secret_name, + ), + ) async def remove_mfa(self, scope: str) -> None: """Delete MFA keys by scope. @@ -46,7 +55,16 @@ async def get_mfa( :param mfa_creds_ldap: MFA_LDAP_Creds :return: MFAGetResponse """ - return await self._service.get_mfa(mfa_creds, mfa_creds_ldap) + mfa_get_response = await self._service.get_mfa( + mfa_creds, + mfa_creds_ldap, + ) + return MFAGetResponse( + mfa_key=mfa_get_response.mfa_key, + mfa_secret=mfa_get_response.mfa_secret, + mfa_key_ldap=mfa_get_response.mfa_key_ldap, + mfa_secret_ldap=mfa_get_response.mfa_secret_ldap, + ) async def callback_mfa( self, diff --git a/app/api/auth/schemas.py b/app/api/auth/schemas.py index 5473e4184..07ee8dcfa 100644 --- a/app/api/auth/schemas.py +++ b/app/api/auth/schemas.py @@ -83,12 +83,5 @@ class MFAGetResponse(BaseModel): class MFAChallengeResponse(BaseModel): """MFA Challenge state.""" - status: str - message: str - - -class LoginResponse(BaseModel): - """Login response.""" - - session_key: str | None - mfa_challenge: MFAChallengeResponse | None + status: str | None + message: str | None diff --git a/app/ldap_protocol/auth/auth_manager.py b/app/ldap_protocol/auth/auth_manager.py index 605c2d607..49dea83b3 100644 --- a/app/ldap_protocol/auth/auth_manager.py +++ b/app/ldap_protocol/auth/auth_manager.py @@ -11,11 +11,10 @@ from starlette.datastructures import URL from abstract_service import AbstractService -from api.auth.schemas import LoginResponse, OAuth2Form from config import Settings from entities import User -from enums import AuthorizationRules, MFAFlags -from ldap_protocol.auth.dto import SetupDTO +from enums import AuthorizationRules, MFAChallengeStatuses, MFAFlags +from ldap_protocol.auth.dto import LoginRequestDTO, LoginResponseDTO, SetupDTO from ldap_protocol.auth.mfa_manager import MFAManager from ldap_protocol.auth.use_cases import SetupUseCase from ldap_protocol.auth.utils import authenticate_user @@ -100,11 +99,11 @@ def __getattribute__(self, name: str) -> object: async def login( self, - form: OAuth2Form, + form: LoginRequestDTO, url: URL, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> LoginResponse: + ) -> LoginResponseDTO: """Log in a user. :param form: OAuth2Form with username and password @@ -169,8 +168,8 @@ async def login( ) if request_2fa: ( - mfa_challenge, - key, + mfa_challenge_dto, + session_key, ) = await self._mfa_manager.two_factor_protocol( user=user, network_policy=network_policy, @@ -178,9 +177,9 @@ async def login( ip=ip, user_agent=user_agent, ) - return LoginResponse( - session_key=key, - mfa_challenge=mfa_challenge, + return LoginResponseDTO[MFAChallengeStatuses]( + session_key=session_key, + mfa_challenge=mfa_challenge_dto, ) session_key = await self._repository.create_session_key( @@ -189,7 +188,9 @@ async def login( user_agent, self.key_ttl, ) - return LoginResponse(session_key=session_key, mfa_challenge=None) + return LoginResponseDTO[None]( + session_key=session_key, + ) async def _update_password( self, diff --git a/app/ldap_protocol/auth/dto.py b/app/ldap_protocol/auth/dto.py index 909c0f35e..16e98bd37 100644 --- a/app/ldap_protocol/auth/dto.py +++ b/app/ldap_protocol/auth/dto.py @@ -5,6 +5,23 @@ """ from dataclasses import dataclass +from typing import Generic, TypeVar + +from enums import MFAChallengeStatuses + +_MfaChallengeStatuses = TypeVar( + "_MfaChallengeStatuses", + MFAChallengeStatuses, + None, +) + + +@dataclass +class LoginRequestDTO: + """Login request DTO.""" + + username: str + password: str @dataclass @@ -17,3 +34,40 @@ class SetupDTO: display_name: str mail: str password: str + + +@dataclass +class MFAChallengeResponseDTO: + """MFA challenge response DTO.""" + + status: MFAChallengeStatuses + message: str + + +@dataclass +class LoginResponseDTO(Generic[_MfaChallengeStatuses]): + """Login response DTO.""" + + session_key: str | None + mfa_challenge: MFAChallengeResponseDTO = None # type: ignore[assignment] + + +@dataclass +class MFACreateRequestDTO: + """MFA create request DTO.""" + + mfa_key: str + mfa_secret: str + is_ldap_scope: bool + secret_name: str + key_name: str + + +@dataclass +class MFAGetResponseDTO: + """MFA get response DTO.""" + + mfa_key: str | None + mfa_secret: str | None + mfa_key_ldap: str | None + mfa_secret_ldap: str | None diff --git a/app/ldap_protocol/auth/mfa_manager.py b/app/ldap_protocol/auth/mfa_manager.py index 9c20097a8..0e24f2285 100644 --- a/app/ldap_protocol/auth/mfa_manager.py +++ b/app/ldap_protocol/auth/mfa_manager.py @@ -18,14 +18,14 @@ from starlette.datastructures import URL from abstract_service import AbstractService -from api.auth.schemas import ( - MFAChallengeResponse, - MFACreateRequest, - MFAGetResponse, -) from config import Settings from entities import CatalogueSetting, NetworkPolicy, User from enums import AuthorizationRules, MFAChallengeStatuses, MFAFlags +from ldap_protocol.auth.dto import ( + MFAChallengeResponseDTO, + MFACreateRequestDTO, + MFAGetResponseDTO, +) from ldap_protocol.auth.exceptions.mfa import ( AuthenticationError, ForbiddenError, @@ -102,10 +102,10 @@ def __getattribute__(self, name: str) -> object: return self._monitor.wrap_proxy_request(attr) return attr - async def setup_mfa(self, mfa: MFACreateRequest) -> bool: + async def setup_mfa(self, mfa: MFACreateRequestDTO) -> bool: """Create or update MFA keys. - :param mfa: MFACreateRequest + :param mfa: MFACreateRequestDTO :return: bool """ async with self._session.begin_nested(): @@ -151,12 +151,12 @@ async def get_mfa( self, mfa_creds: MFA_HTTP_Creds | None, mfa_creds_ldap: MFA_LDAP_Creds | None, - ) -> MFAGetResponse: + ) -> MFAGetResponseDTO: """Get MFA keys for http and ldap. :param mfa_creds: MFA_HTTP_Creds or None :param mfa_creds_ldap: MFA_LDAP_Creds or None - :return: MFAGetResponse + :return: MFAGetResponseDTO """ if not mfa_creds: mfa_creds = MFA_HTTP_Creds(Creds(None, None)) @@ -164,7 +164,7 @@ async def get_mfa( if not mfa_creds_ldap: mfa_creds_ldap = MFA_LDAP_Creds(Creds(None, None)) - return MFAGetResponse( + return MFAGetResponseDTO( mfa_key=mfa_creds.key, mfa_secret=mfa_creds.secret, mfa_key_ldap=mfa_creds_ldap.key, @@ -219,14 +219,14 @@ async def _create_bypass_data( message: str, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> tuple[MFAChallengeResponse, str | None]: + ) -> tuple[MFAChallengeResponseDTO, str | None]: """Create session key and response. :param user: User :param message: str :param ip: IPv4Address | IPv6Address :param user_agent: str - :return: tuple[MFAChallengeResponse, str | None] + :return: tuple[MFAChallengeResponseDTO, str | None] """ key = await self._repository.create_session_key( user, @@ -235,7 +235,7 @@ async def _create_bypass_data( self.key_ttl, ) return ( - MFAChallengeResponse( + MFAChallengeResponseDTO( status=MFAChallengeStatuses.BYPASS, message=message, ), @@ -249,7 +249,7 @@ async def two_factor_protocol( url: URL, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> tuple[MFAChallengeResponse, str | None]: + ) -> tuple[MFAChallengeResponseDTO, str | None]: """Initiate two-factor protocol with application. :param user: User @@ -258,7 +258,7 @@ async def two_factor_protocol( :param ip: IP address :param user_agent: User-Agent string :return: - tuple[MFAChallengeResponse, str | None] (session key | None) + tuple[MFAChallengeResponseDTO, str | None] (session key | None) :raises MissingMFACredentialsError: if MFA is not initialized :raises InvalidCredentialsError: if credentials are invalid :raises NetworkPolicyError: if network policy is not passed @@ -300,7 +300,7 @@ async def two_factor_protocol( weakref.finalize(bypass_coro, bypass_coro.close) return ( - MFAChallengeResponse( + MFAChallengeResponseDTO( status=MFAChallengeStatuses.PENDING, message=redirect_url, ), diff --git a/app/ldap_protocol/dhcp/dtos.py b/app/ldap_protocol/dhcp/dtos.py index f5cad3041..2b7c097a0 100644 --- a/app/ldap_protocol/dhcp/dtos.py +++ b/app/ldap_protocol/dhcp/dtos.py @@ -1,4 +1,4 @@ -"""Schemas for DHCP manager. +"""DTOs for DHCP manager. Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE diff --git a/app/ldap_protocol/policies/audit/dataclasses.py b/app/ldap_protocol/policies/audit/dataclasses.py index 4c7d48ab8..093b92ff7 100644 --- a/app/ldap_protocol/policies/audit/dataclasses.py +++ b/app/ldap_protocol/policies/audit/dataclasses.py @@ -14,14 +14,6 @@ from ldap_protocol.objects import OperationEvent -@dataclass -class OAuth2FormDTO: - """OAuth2 form data transfer object.""" - - username: str - password: str - - @dataclass class AuditPolicyTriggerDTO: """Audit policy trigger data transfer object.""" diff --git a/app/ldap_protocol/policies/audit/monitor.py b/app/ldap_protocol/policies/audit/monitor.py index 59e40e8bb..6258daf49 100644 --- a/app/ldap_protocol/policies/audit/monitor.py +++ b/app/ldap_protocol/policies/audit/monitor.py @@ -14,6 +14,7 @@ from config import Settings from entities import User +from ldap_protocol.auth.dto import LoginRequestDTO from ldap_protocol.auth.exceptions.mfa import ( AuthenticationError, ForbiddenError, @@ -34,7 +35,6 @@ from ldap_protocol.multifactor import MFA_HTTP_Creds from ldap_protocol.objects import OperationEvent from ldap_protocol.policies.audit.audit_use_case import AuditUseCase -from ldap_protocol.policies.audit.dataclasses import OAuth2FormDTO from ldap_protocol.policies.audit.events.factory import ( RawAuditEventBuilderRedis, ) @@ -224,7 +224,7 @@ async def wrapped_proxy_request( def wrap_login(self, attr: _T) -> _T: @wraps(attr) async def wrapped_login( - form: OAuth2FormDTO, + form: LoginRequestDTO, url: URL, ip: IPv4Address | IPv6Address, user_agent: str, From 651c7b803dc70abecb02527f96e51793f1e2461e Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 3 Mar 2026 10:00:03 +0300 Subject: [PATCH 6/7] refactor: update MFAChallengeResponse schema to remove optional fields --- app/api/auth/schemas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/auth/schemas.py b/app/api/auth/schemas.py index 07ee8dcfa..3102f2b37 100644 --- a/app/api/auth/schemas.py +++ b/app/api/auth/schemas.py @@ -83,5 +83,5 @@ class MFAGetResponse(BaseModel): class MFAChallengeResponse(BaseModel): """MFA Challenge state.""" - status: str | None - message: str | None + status: str + message: str From cb2bba6d7e132fc958145af256123b5cc9a16afc Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 3 Mar 2026 10:15:53 +0300 Subject: [PATCH 7/7] refactor: enhance LoginResponseDTO and update return types for MFA handling --- app/api/auth/adapters/auth.py | 3 ++- app/ldap_protocol/auth/auth_manager.py | 7 ++++--- app/ldap_protocol/auth/dto.py | 11 ++--------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/api/auth/adapters/auth.py b/app/api/auth/adapters/auth.py index ef6a64e1b..bb7f766fb 100644 --- a/app/api/auth/adapters/auth.py +++ b/app/api/auth/adapters/auth.py @@ -38,7 +38,7 @@ async def login( :raises HTTPException: 403 if access is forbidden (e.g. not in admins, disabled, expired, or policy failed) :raises HTTPException: 426 if MFA is required - :return: None + :return: MFAChallengeResponse | None """ login_dto = await self._service.login( form=LoginRequestDTO( @@ -58,6 +58,7 @@ async def login( status=login_dto.mfa_challenge.status, message=login_dto.mfa_challenge.message, ) + return None async def reset_password( self, diff --git a/app/ldap_protocol/auth/auth_manager.py b/app/ldap_protocol/auth/auth_manager.py index 49dea83b3..6dff8b69e 100644 --- a/app/ldap_protocol/auth/auth_manager.py +++ b/app/ldap_protocol/auth/auth_manager.py @@ -13,7 +13,7 @@ from abstract_service import AbstractService from config import Settings from entities import User -from enums import AuthorizationRules, MFAChallengeStatuses, MFAFlags +from enums import AuthorizationRules, MFAFlags from ldap_protocol.auth.dto import LoginRequestDTO, LoginResponseDTO, SetupDTO from ldap_protocol.auth.mfa_manager import MFAManager from ldap_protocol.auth.use_cases import SetupUseCase @@ -177,7 +177,7 @@ async def login( ip=ip, user_agent=user_agent, ) - return LoginResponseDTO[MFAChallengeStatuses]( + return LoginResponseDTO( session_key=session_key, mfa_challenge=mfa_challenge_dto, ) @@ -188,8 +188,9 @@ async def login( user_agent, self.key_ttl, ) - return LoginResponseDTO[None]( + return LoginResponseDTO( session_key=session_key, + mfa_challenge=None, ) async def _update_password( diff --git a/app/ldap_protocol/auth/dto.py b/app/ldap_protocol/auth/dto.py index 16e98bd37..80ac483d0 100644 --- a/app/ldap_protocol/auth/dto.py +++ b/app/ldap_protocol/auth/dto.py @@ -5,16 +5,9 @@ """ from dataclasses import dataclass -from typing import Generic, TypeVar from enums import MFAChallengeStatuses -_MfaChallengeStatuses = TypeVar( - "_MfaChallengeStatuses", - MFAChallengeStatuses, - None, -) - @dataclass class LoginRequestDTO: @@ -45,11 +38,11 @@ class MFAChallengeResponseDTO: @dataclass -class LoginResponseDTO(Generic[_MfaChallengeStatuses]): +class LoginResponseDTO: """Login response DTO.""" session_key: str | None - mfa_challenge: MFAChallengeResponseDTO = None # type: ignore[assignment] + mfa_challenge: MFAChallengeResponseDTO | None @dataclass