auth service completed and tested

This commit is contained in:
2025-01-14 19:16:24 +03:00
parent 08b1815156
commit 486fadbfb3
33 changed files with 1325 additions and 248 deletions

View File

@@ -11,7 +11,7 @@ from ApiLibrary import system_arrow
class PyObjectId(ObjectId):
"""Custom type for handling MongoDB ObjectId in Pydantic models."""
@classmethod
def __get_pydantic_core_schema__(
cls,
@@ -21,17 +21,21 @@ class PyObjectId(ObjectId):
"""Define the core schema for PyObjectId."""
return core_schema.json_or_python_schema(
json_schema=core_schema.str_schema(),
python_schema=core_schema.union_schema([
core_schema.is_instance_schema(ObjectId),
core_schema.chain_schema([
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(cls.validate),
]),
]),
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(ObjectId),
core_schema.chain_schema(
[
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(cls.validate),
]
),
]
),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda x: str(x),
return_schema=core_schema.str_schema(),
when_used='json',
when_used="json",
),
)
@@ -54,14 +58,14 @@ class PyObjectId(ObjectId):
class MongoBaseModel(BaseModel):
"""Base model for all MongoDB documents."""
model_config = ConfigDict(
arbitrary_types_allowed=True,
json_encoders={ObjectId: str},
populate_by_name=True,
from_attributes=True,
validate_assignment=True,
extra='allow'
extra="allow",
)
# Optional _id field that will be ignored in create operations
@@ -69,11 +73,11 @@ class MongoBaseModel(BaseModel):
def get_extra(self, field_name: str, default: Any = None) -> Any:
"""Safely get extra field value.
Args:
field_name: Name of the extra field to retrieve
default: Default value to return if field doesn't exist
Returns:
Value of the extra field if it exists, otherwise the default value
"""
@@ -81,7 +85,7 @@ class MongoBaseModel(BaseModel):
def as_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary including all fields and extra fields.
Returns:
Dict containing all model fields and extra fields with proper type conversion
"""
@@ -94,18 +98,18 @@ class MongoDocument(MongoBaseModel):
created_at: float = Field(default_factory=lambda: system_arrow.now().timestamp())
updated_at: float = Field(default_factory=lambda: system_arrow.now().timestamp())
@model_validator(mode='before')
@model_validator(mode="before")
@classmethod
def prevent_protected_fields(cls, data: Any) -> Any:
"""Prevent user from setting protected fields like _id and timestamps."""
if isinstance(data, dict):
# Remove protected fields from input
data.pop('_id', None)
data.pop('created_at', None)
data.pop('updated_at', None)
data.pop("_id", None)
data.pop("created_at", None)
data.pop("updated_at", None)
# Set timestamps
data['created_at'] = system_arrow.now().timestamp()
data['updated_at'] = system_arrow.now().timestamp()
data["created_at"] = system_arrow.now().timestamp()
data["updated_at"] = system_arrow.now().timestamp()
return data

View File

@@ -7,7 +7,7 @@ including domain history and access details.
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import Field, model_validator
from pydantic import BaseModel, Field, ConfigDict, model_validator
from ApiLibrary import system_arrow
from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocument
@@ -15,40 +15,38 @@ from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocu
class DomainData(MongoBaseModel):
"""Model for domain data.
Attributes:
user_uu_id: Unique identifier of the user
main_domain: Primary domain
other_domains_list: List of additional domains
extra_data: Additional domain-related data
"""
user_uu_id: str = Field(..., description="User's unique identifier")
main_domain: str = Field(..., description="Primary domain")
other_domains_list: List[str] = Field(
default_factory=list,
description="List of additional domains"
default_factory=list, description="List of additional domains"
)
extra_data: Optional[Dict[str, Any]] = Field(
default_factory=dict,
alias="extraData",
description="Additional domain-related data"
description="Additional domain-related data",
)
class Config:
from_attributes = True
populate_by_name = True
validate_assignment = True
model_config = ConfigDict(
from_attributes=True, populate_by_name=True, validate_assignment=True
)
class DomainDocument(MongoDocument):
"""Model for domain-related documents."""
data: DomainData = Field(..., description="Domain data")
def update_main_domain(self, new_domain: str) -> None:
"""Update the main domain and move current to history.
Args:
new_domain: New main domain to set
"""
@@ -60,19 +58,19 @@ class DomainDocument(MongoDocument):
class DomainDocumentCreate(MongoDocument):
"""Model for creating new domain documents."""
data: DomainData = Field(..., description="Initial domain data")
class Config:
from_attributes = True
populate_by_name = True
validate_assignment = True
model_config = ConfigDict(
from_attributes=True, populate_by_name=True, validate_assignment=True
)
class DomainDocumentUpdate(MongoDocument):
"""Model for updating existing domain documents."""
data: DomainData = Field(..., description="Updated domain data")
class Config:
from_attributes = True
populate_by_name = True
validate_assignment = True
model_config = ConfigDict(
from_attributes=True, populate_by_name=True, validate_assignment=True
)

View File

@@ -15,7 +15,7 @@ from Services.MongoDb.Models.action_models.base import MongoBaseModel, MongoDocu
class PasswordHistoryDetail(MongoBaseModel):
"""Model for password history details."""
timestamp: datetime
ip_address: Optional[str] = Field(None, alias="ipAddress")
user_agent: Optional[str] = Field(None, alias="userAgent")
@@ -24,27 +24,26 @@ class PasswordHistoryDetail(MongoBaseModel):
class PasswordHistoryData(MongoBaseModel):
"""Model for password history data."""
password_history: List[str] = Field([], alias="passwordHistory")
access_history_detail: Dict[str, PasswordHistoryDetail] = Field(
default_factory=dict,
alias="accessHistoryDetail"
default_factory=dict, alias="accessHistoryDetail"
)
class PasswordDocument(MongoDocument):
"""Model for password-related documents."""
data: PasswordHistoryData
class PasswordDocumentCreate(MongoBaseModel):
"""Model for creating new password documents."""
data: PasswordHistoryData = Field(..., description="Initial password data")
class PasswordDocumentUpdate(MongoBaseModel):
"""Model for updating existing password documents."""
data: PasswordHistoryData

View File

@@ -30,23 +30,19 @@ class MongoActions(
MongoInsertMixin,
MongoFindMixin,
MongoDeleteMixin,
MongoAggregateMixin
MongoAggregateMixin,
):
"""Main MongoDB actions class that inherits all CRUD operation mixins.
This class provides a unified interface for all MongoDB operations while
managing collections based on company UUID and storage reason.
"""
def __init__(
self,
client: MongoClient,
database: str,
company_uuid: str,
storage_reason: str
self, client: MongoClient, database: str, company_uuid: str, storage_reason: str
):
"""Initialize MongoDB actions with client and collection info.
Args:
client: MongoDB client
database: Database name to use
@@ -62,7 +58,7 @@ class MongoActions(
def use_collection(self, storage_reason: str) -> None:
"""Switch to a different collection.
Args:
storage_reason: New storage reason for collection naming
"""
@@ -82,7 +78,9 @@ class MongoActions(
"""Insert multiple documents."""
return super().insert_many(self.collection, documents)
def find_one(self, filter_query: Dict[str, Any], projection: Optional[Dict[str, Any]] = None):
def find_one(
self, filter_query: Dict[str, Any], projection: Optional[Dict[str, Any]] = None
):
"""Find a single document."""
return super().find_one(self.collection, filter_query, projection)
@@ -96,12 +94,7 @@ class MongoActions(
):
"""Find multiple documents."""
return super().find_many(
self.collection,
filter_query,
projection,
sort,
limit,
skip
self.collection, filter_query, projection, sort, limit, skip
)
def update_one(
@@ -111,12 +104,7 @@ class MongoActions(
upsert: bool = False,
):
"""Update a single document."""
return super().update_one(
self.collection,
filter_query,
update_data,
upsert
)
return super().update_one(self.collection, filter_query, update_data, upsert)
def update_many(
self,
@@ -125,12 +113,7 @@ class MongoActions(
upsert: bool = False,
):
"""Update multiple documents."""
return super().update_many(
self.collection,
filter_query,
update_data,
upsert
)
return super().update_many(self.collection, filter_query, update_data, upsert)
def delete_one(self, filter_query: Dict[str, Any]):
"""Delete a single document."""

View File

@@ -19,27 +19,27 @@ from Services.MongoDb.Models.exceptions import (
PasswordHistoryError,
PasswordReuseError,
PasswordHistoryLimitError,
InvalidPasswordDetailError
InvalidPasswordDetailError,
)
from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi
def handle_mongo_errors(func: Callable) -> Callable:
"""Decorator to handle MongoDB operation errors.
Args:
func: Function to wrap with error handling
Returns:
Wrapped function with error handling
"""
async def wrapper(*args, **kwargs) -> Any:
try:
return await func(*args, **kwargs)
except ConnectionFailure as e:
raise MongoConnectionError(
message=str(e),
details={"error_type": "connection_failure"}
message=str(e), details={"error_type": "connection_failure"}
).to_http_exception()
except DuplicateKeyError as e:
raise MongoDuplicateKeyError(
@@ -48,143 +48,138 @@ def handle_mongo_errors(func: Callable) -> Callable:
).to_http_exception()
except PyMongoError as e:
raise MongoBaseException(
message=str(e),
details={"error_type": "pymongo_error"}
message=str(e), details={"error_type": "pymongo_error"}
).to_http_exception()
except Exception as e:
raise HTTPExceptionApi(
lang="en",
error_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return wrapper
async def mongo_base_exception_handler(
request: Request,
exc: MongoBaseException
request: Request, exc: MongoBaseException
) -> JSONResponse:
"""Handle base MongoDB exceptions.
Args:
request: FastAPI request
exc: MongoDB base exception
Returns:
JSON response with error details
"""
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.to_http_exception()}
status_code=exc.status_code, content={"error": exc.to_http_exception()}
)
async def mongo_connection_error_handler(
request: Request,
exc: MongoConnectionError
request: Request, exc: MongoConnectionError
) -> JSONResponse:
"""Handle MongoDB connection errors.
Args:
request: FastAPI request
exc: MongoDB connection error
Returns:
JSON response with connection error details
"""
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"error": exc.to_http_exception()}
content={"error": exc.to_http_exception()},
)
async def mongo_document_not_found_handler(
request: Request,
exc: MongoDocumentNotFoundError
request: Request, exc: MongoDocumentNotFoundError
) -> JSONResponse:
"""Handle document not found errors.
Args:
request: FastAPI request
exc: Document not found error
Returns:
JSON response with not found error details
"""
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"error": exc.to_http_exception()}
content={"error": exc.to_http_exception()},
)
async def mongo_validation_error_handler(
request: Request,
exc: MongoValidationError
request: Request, exc: MongoValidationError
) -> JSONResponse:
"""Handle validation errors.
Args:
request: FastAPI request
exc: Validation error
Returns:
JSON response with validation error details
"""
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"error": exc.to_http_exception()}
content={"error": exc.to_http_exception()},
)
async def mongo_duplicate_key_error_handler(
request: Request,
exc: MongoDuplicateKeyError
request: Request, exc: MongoDuplicateKeyError
) -> JSONResponse:
"""Handle duplicate key errors.
Args:
request: FastAPI request
exc: Duplicate key error
Returns:
JSON response with duplicate key error details
"""
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"error": exc.to_http_exception()}
status_code=status.HTTP_409_CONFLICT, content={"error": exc.to_http_exception()}
)
async def password_history_error_handler(
request: Request,
exc: PasswordHistoryError
request: Request, exc: PasswordHistoryError
) -> JSONResponse:
"""Handle password history errors.
Args:
request: FastAPI request
exc: Password history error
Returns:
JSON response with password history error details
"""
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.to_http_exception()}
status_code=exc.status_code, content={"error": exc.to_http_exception()}
)
def register_exception_handlers(app: Any) -> None:
"""Register all MongoDB exception handlers with FastAPI app.
Args:
app: FastAPI application instance
"""
app.add_exception_handler(MongoBaseException, mongo_base_exception_handler)
app.add_exception_handler(MongoConnectionError, mongo_connection_error_handler)
app.add_exception_handler(MongoDocumentNotFoundError, mongo_document_not_found_handler)
app.add_exception_handler(
MongoDocumentNotFoundError, mongo_document_not_found_handler
)
app.add_exception_handler(MongoValidationError, mongo_validation_error_handler)
app.add_exception_handler(MongoDuplicateKeyError, mongo_duplicate_key_error_handler)
app.add_exception_handler(PasswordHistoryError, password_history_error_handler)
app.add_exception_handler(PasswordReuseError, password_history_error_handler)
app.add_exception_handler(PasswordHistoryLimitError, password_history_error_handler)
app.add_exception_handler(InvalidPasswordDetailError, password_history_error_handler)
app.add_exception_handler(
InvalidPasswordDetailError, password_history_error_handler
)

View File

@@ -12,12 +12,12 @@ from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi
class MongoBaseException(Exception):
"""Base exception for MongoDB-related errors."""
def __init__(
self,
message: str,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
details: Optional[Dict[str, Any]] = None
details: Optional[Dict[str, Any]] = None,
):
self.message = message
self.status_code = status_code
@@ -34,128 +34,110 @@ class MongoBaseException(Exception):
class MongoConnectionError(MongoBaseException):
"""Raised when there's an error connecting to MongoDB."""
def __init__(
self,
message: str = "Failed to connect to MongoDB",
details: Optional[Dict[str, Any]] = None
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
details=details
details=details,
)
class MongoDocumentNotFoundError(MongoBaseException):
"""Raised when a document is not found in MongoDB."""
def __init__(
self,
collection: str,
filter_query: Dict[str, Any],
message: Optional[str] = None
message: Optional[str] = None,
):
message = message or f"Document not found in collection '{collection}'"
super().__init__(
message=message,
status_code=status.HTTP_404_NOT_FOUND,
details={
"collection": collection,
"filter": filter_query
}
details={"collection": collection, "filter": filter_query},
)
class MongoValidationError(MongoBaseException):
"""Raised when document validation fails."""
def __init__(
self,
message: str,
field_errors: Optional[Dict[str, str]] = None
):
def __init__(self, message: str, field_errors: Optional[Dict[str, str]] = None):
super().__init__(
message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details={"field_errors": field_errors or {}}
details={"field_errors": field_errors or {}},
)
class MongoDuplicateKeyError(MongoBaseException):
"""Raised when trying to insert a document with a duplicate key."""
def __init__(
self,
collection: str,
key_pattern: Dict[str, Any],
message: Optional[str] = None
message: Optional[str] = None,
):
message = message or f"Duplicate key error in collection '{collection}'"
super().__init__(
message=message,
status_code=status.HTTP_409_CONFLICT,
details={
"collection": collection,
"key_pattern": key_pattern
}
details={"collection": collection, "key_pattern": key_pattern},
)
class PasswordHistoryError(MongoBaseException):
"""Base exception for password history-related errors."""
def __init__(
self,
message: str,
status_code: int = status.HTTP_400_BAD_REQUEST,
details: Optional[Dict[str, Any]] = None
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, status_code, details)
class PasswordReuseError(PasswordHistoryError):
"""Raised when attempting to reuse a recent password."""
def __init__(
self,
message: str = "Password was used recently",
history_limit: Optional[int] = None
history_limit: Optional[int] = None,
):
details = {"history_limit": history_limit} if history_limit else None
super().__init__(
message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details=details
details=details,
)
class PasswordHistoryLimitError(PasswordHistoryError):
"""Raised when password history limit is reached."""
def __init__(
self,
limit: int,
message: Optional[str] = None
):
def __init__(self, limit: int, message: Optional[str] = None):
message = message or f"Password history limit of {limit} reached"
super().__init__(
message=message,
status_code=status.HTTP_409_CONFLICT,
details={"limit": limit}
details={"limit": limit},
)
class InvalidPasswordDetailError(PasswordHistoryError):
"""Raised when password history detail is invalid."""
def __init__(
self,
message: str,
field_errors: Optional[Dict[str, str]] = None
):
def __init__(self, message: str, field_errors: Optional[Dict[str, str]] = None):
super().__init__(
message=message,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details={"field_errors": field_errors or {}}
details={"field_errors": field_errors or {}},
)

View File

@@ -34,25 +34,16 @@ def handle_mongo_errors(func):
try:
return func(*args, **kwargs)
except ConnectionFailure:
raise HTTPExceptionApi(
error_code="HTTP_503_SERVICE_UNAVAILABLE",
lang="en"
)
raise HTTPExceptionApi(error_code="HTTP_503_SERVICE_UNAVAILABLE", lang="en")
except ServerSelectionTimeoutError:
raise HTTPExceptionApi(
error_code="HTTP_504_GATEWAY_TIMEOUT",
lang="en"
)
raise HTTPExceptionApi(error_code="HTTP_504_GATEWAY_TIMEOUT", lang="en")
except OperationFailure as e:
raise HTTPExceptionApi(
error_code="HTTP_400_BAD_REQUEST",
lang="en"
)
raise HTTPExceptionApi(error_code="HTTP_400_BAD_REQUEST", lang="en")
except PyMongoError as e:
raise HTTPExceptionApi(
error_code="HTTP_500_INTERNAL_SERVER_ERROR",
lang="en"
error_code="HTTP_500_INTERNAL_SERVER_ERROR", lang="en"
)
return wrapper

View File

@@ -42,11 +42,15 @@ class MongoFindMixin:
class MongoUpdateMixin:
"""Mixin for MongoDB update operations."""
def update_one(self, filter_query: Dict[str, Any], update: Dict[str, Any]) -> UpdateResult:
def update_one(
self, filter_query: Dict[str, Any], update: Dict[str, Any]
) -> UpdateResult:
"""Update a single document."""
return self.collection.update_one(filter_query, update)
def update_many(self, filter_query: Dict[str, Any], update: Dict[str, Any]) -> UpdateResult:
def update_many(
self, filter_query: Dict[str, Any], update: Dict[str, Any]
) -> UpdateResult:
"""Update multiple documents."""
return self.collection.update_many(filter_query, update)
@@ -100,7 +104,7 @@ class MongoDBHandler(
)
else:
connection_url = f"mongodb://{MongoConfig.HOST}:{MongoConfig.PORT}"
# Build connection options
connection_kwargs = {
"host": connection_url,
@@ -110,11 +114,11 @@ class MongoDBHandler(
"waitQueueTimeoutMS": 2000, # How long a thread will wait for a connection
"serverSelectionTimeoutMS": 5000, # How long to wait for server selection
}
self._client = MongoClient(**connection_kwargs)
# Test connection
self._client.admin.command('ping')
self._client.admin.command("ping")
def close(self):
"""Close MongoDB connection."""

View File

@@ -3,8 +3,7 @@ from functools import lru_cache
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session, Session
from sqlalchemy.orm import declarative_base, sessionmaker, scoped_session, Session
from AllConfigs.SqlDatabase.configs import WagDatabase