diff --git a/app/authentication/backend/application/signin_hub.py b/app/authentication/backend/application/signin_hub.py index 37b2c69..a0494a9 100644 --- a/app/authentication/backend/application/signin_hub.py +++ b/app/authentication/backend/application/signin_hub.py @@ -67,6 +67,16 @@ class SignInHub: user_id=user_id, password=password ) + @log_entry_exit_async + async def send_email_code(self, sender_id: str, email: str) -> dict[str, any]: + result = await self.signin_manager.send_email_code(sender_id, email) + return {"succeeded": result} + + @log_entry_exit_async + async def send_mobile_code(self, sender_id: str, mobile: str) -> dict[str, any]: + result = await self.signin_manager.send_mobile_code(sender_id, mobile) + return {"succeeded": result} + @log_entry_exit_async async def sign_out(self, identity: str) -> bool: # TODO: to be implemented diff --git a/app/authentication/backend/business/signin_manager.py b/app/authentication/backend/business/signin_manager.py index 3b5a0ab..5016f14 100644 --- a/app/authentication/backend/business/signin_manager.py +++ b/app/authentication/backend/business/signin_manager.py @@ -16,6 +16,12 @@ from app.authentication.backend.services.code_depot.code_depot_service import ( from infra.log.module_logger import ModuleLogger from infra.utils.string import check_password_complexity from infra.exception.exceptions import InvalidDataError +from app.authentication.backend.services.notification.notification_service import ( + NotificationService, +) +from app.authentication.backend.models.user.constants import ( + AuthType, +) class SignInManager: @@ -25,6 +31,7 @@ class SignInManager: self.user_management_service = UserManagementService() self.module_logger = ModuleLogger(sender_id=SignInManager) self.code_depot_service = CodeDepotService() + self.notification_service = NotificationService() # TODO: Dax: notification service # self.event_service = EventService() @@ -319,3 +326,31 @@ class SignInManager: user_id, user_flid, password ) return {"succeeded": True} + + async def send_email_code(self, sender_id: str, email: str) -> bool: + mail_code = await self.user_auth_service.generate_auth_code_for_object( + email, AuthType.EMAIL + ) + success = await self.notification_service.send_notification( + sender_id=sender_id, + channels=["email"], + receiver_id=email, + subject="email", + event="authentication", + properties={"auth_code": mail_code}, + ) + return success + + async def send_mobile_code(self, sender_id: str, mobile: str) -> bool: + mail_code = await self.user_auth_service.generate_auth_code_for_object( + mobile, AuthType.MOBILE + ) + success = await self.notification_service.send_notification( + sender_id=sender_id, + channels=["email"], + receiver_id=mobile, + subject="mobile", + event="authentication", + properties={"auth_code": mail_code}, + ) + return success diff --git a/app/authentication/backend/infra/auth/user_auth_handler.py b/app/authentication/backend/infra/auth/user_auth_handler.py index 0b7b1ad..57f2b83 100644 --- a/app/authentication/backend/infra/auth/user_auth_handler.py +++ b/app/authentication/backend/infra/auth/user_auth_handler.py @@ -6,8 +6,6 @@ from infra.utils.string import generate_auth_code from app.authentication.backend.infra.code_management.depot_handler import ( CodeDepotHandler, ) - - from app.authentication.backend.models.user.constants import ( AuthType, ) @@ -331,18 +329,19 @@ class UserAuthHandler: else: return False - async def generate_auth_code(self, email: str) -> str: + async def generate_auth_code(self, deliver_object: str, auth_type: AuthType) -> str: """send auth code to email address Args: - email (str): email address + deliver_object (str): email address, mobile, etc + auth_type (str): authentication type """ auth_code = generate_auth_code() expiry = datetime.now(timezone.utc) + timedelta(minutes=5) auth_code_doc = AuthCodeDoc( auth_code=auth_code, - method=email.lower(), - method_type=AuthType.EMAIL, + method=deliver_object.lower(), + method_type=auth_type, expiry=expiry, ) diff --git a/app/authentication/backend/services/auth/user_auth_service.py b/app/authentication/backend/services/auth/user_auth_service.py index 17ac7f3..4700264 100644 --- a/app/authentication/backend/services/auth/user_auth_service.py +++ b/app/authentication/backend/services/auth/user_auth_service.py @@ -1,6 +1,9 @@ from app.authentication.backend.infra.auth.user_auth_handler import ( UserAuthHandler, ) +from app.authentication.backend.models.user.constants import ( + AuthType, +) from typing import Optional @@ -29,8 +32,12 @@ class UserAuthService: async def update_flid(self, user_id: str, user_flid: str) -> str: return await self.user_auth_handler.update_flid(user_id, user_flid) - async def generate_auth_code_for_email(self, email: str) -> str: - return await self.user_auth_handler.generate_auth_code(email) + async def generate_auth_code_for_object( + self, deliver_object: str, auth_type: AuthType + ) -> str: + return await self.user_auth_handler.generate_auth_code( + deliver_object, auth_type + ) async def verify_user_with_password(self, user_id: str, password: str) -> bool: return await self.user_auth_handler.verify_user_with_password(user_id, password) diff --git a/app/authentication/backend/services/notification/notification_service.py b/app/authentication/backend/services/notification/notification_service.py new file mode 100644 index 0000000..460d250 --- /dev/null +++ b/app/authentication/backend/services/notification/notification_service.py @@ -0,0 +1,35 @@ +import httpx +from pydantic import BaseModel +from typing import Dict, List + + +class NotificationService: + def __init__(self, base_url: str = "http://localhost:8003"): + self.base_url = base_url + + async def send_notification( + self, + sender_id: str, + channels: List[str], + receiver_id: str, + subject: str, + event: str, + properties: Dict, + ) -> bool: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/send_notification", + json={ + "sender_id": sender_id, + "channels": channels, + "receiver_id": receiver_id, + "subject": subject, + "event": event, + "properties": properties, + }, + ) + if response.status_code == 200: + return True + else: + # Optionally log or handle errors here + return False diff --git a/app/authentication/requirements.txt b/app/authentication/requirements.txt index 4cb839e..f9cad3a 100644 --- a/app/authentication/requirements.txt +++ b/app/authentication/requirements.txt @@ -8,6 +8,7 @@ uvicorn==0.23.2 beanie==1.21.0 jieba==0.42.1 aio-pika +httpx pydantic-settings python-jose passlib[bcrypt] \ No newline at end of file diff --git a/app/authentication/webapi/routes/__init__.py b/app/authentication/webapi/routes/__init__.py index 4d3baaa..3151997 100644 --- a/app/authentication/webapi/routes/__init__.py +++ b/app/authentication/webapi/routes/__init__.py @@ -1,8 +1,10 @@ from fastapi import APIRouter from .signin import router as signin_router from .tokens import router as token_router +from .auth import router as auth_router api_router = APIRouter() api_router.include_router(signin_router, tags=["user"]) -api_router.include_router(token_router, tags=["auth"]) +api_router.include_router(token_router, tags=["token"]) +api_router.include_router(auth_router, tags=["auth"]) websocket_router = APIRouter() diff --git a/app/authentication/webapi/routes/auth/__init__.py b/app/authentication/webapi/routes/auth/__init__.py new file mode 100644 index 0000000..355e2b6 --- /dev/null +++ b/app/authentication/webapi/routes/auth/__init__.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from .send_email_code import router as sec_router +from .send_mobile_code import router as smc_router + + +router = APIRouter(prefix="/auth") + +router.include_router(sec_router, tags=["authentication"]) +router.include_router(smc_router, tags=["authentication"]) diff --git a/app/authentication/webapi/routes/auth/send_email_code.py b/app/authentication/webapi/routes/auth/send_email_code.py new file mode 100644 index 0000000..1285339 --- /dev/null +++ b/app/authentication/webapi/routes/auth/send_email_code.py @@ -0,0 +1,41 @@ +from app.authentication.backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from infra.token.token_manager import TokenManager +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +router = APIRouter() +token_manager = TokenManager() +# Web API +# send_email_code +# + + +class RequestIn(BaseModel): + email: str + sender_id: str + + +@router.post( + "/send-email-code", + operation_id="send-email-code", + summary="Send a verification code to the specified email address", + description="This API requires an authenticated user and will send a code to the specified email. \ + The code can be used later to verify the email address.", + response_description="Indicates success or failure of the email code send operation", +) +async def send_email_code( + item: RequestIn, + current_user: dict = Depends(token_manager.get_current_user), +): + user_id = current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + + result = await SignInHub(user_id).send_email_code(item.sender_id, item.email) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/authentication/webapi/routes/auth/send_mobile_code.py b/app/authentication/webapi/routes/auth/send_mobile_code.py new file mode 100644 index 0000000..cd421a3 --- /dev/null +++ b/app/authentication/webapi/routes/auth/send_mobile_code.py @@ -0,0 +1,40 @@ +from app.authentication.backend.application.signin_hub import SignInHub +from pydantic import BaseModel +from infra.token.token_manager import TokenManager +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, HTTPException +from starlette.status import HTTP_401_UNAUTHORIZED + +router = APIRouter() +token_manager = TokenManager() +# Web API +# send_email_code +# + + +class RequestIn(BaseModel): + email: str + + +@router.post( + "/send-mobile-code", + operation_id="send-mobile-code", + summary="Send a verification code to the specified mobile number", + description="This API requires an authenticated user and will send a code to the specified mobile. \ + The code can be used later to verify the mobile.", + response_description="Indicates success or failure of the mobile code send operation", +) +async def send_email_code( + item: RequestIn, + current_user: dict = Depends(token_manager.get_current_user), +): + user_id = current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" + ) + + result = await SignInHub(user_id).send_mobile_code(item.email) + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/notification/Dockerfile b/app/notification/Dockerfile index e69de29..4136969 100644 --- a/app/notification/Dockerfile +++ b/app/notification/Dockerfile @@ -0,0 +1,19 @@ +# Dockerfile for Python Service +FROM python:3.10-slim + +# Set the working directory inside the container +WORKDIR /app + +# Copy the requirements.txt to the working directory and install dependencies +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the application code to the working directory +COPY . /app + +# Expose the port used by the FastAPI app +EXPOSE 8003 + + +# Run the application using the start script +CMD ["uvicorn", "app.notification.webapi.main:app", "--reload", "--port=8003", "--host=0.0.0.0"] diff --git a/app/notification/backend/services/notification_publisher_service.py b/app/notification/backend/services/notification_publisher_service.py index c0a311f..da2159e 100644 --- a/app/notification/backend/services/notification_publisher_service.py +++ b/app/notification/backend/services/notification_publisher_service.py @@ -1,5 +1,5 @@ from app.notification.backend.models.constants import NotificationChannel -from backend.infra.rabbitmq.async_publisher import AsyncMQPublisher +from app.notification.backend.infra.rabbitmq.async_publisher import AsyncMQPublisher class NotificationPublisherService: diff --git a/app/notification/requirements.txt b/app/notification/requirements.txt index e69de29..36afe9a 100644 --- a/app/notification/requirements.txt +++ b/app/notification/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.114.0 +fastapi-jwt==0.2.0 +pika==1.3.2 +pydantic==2.9.2 +uvicorn==0.23.2 +beanie==1.21.0 +httpx==0.24.1 +loguru==0.7.2 +sendgrid +aio-pika +twilio +pydantic-settings +python-multipart +python-jose \ No newline at end of file diff --git a/app/notification/webapi/bootstrap/application.py b/app/notification/webapi/bootstrap/application.py index 7a38aa7..8fc2caf 100644 --- a/app/notification/webapi/bootstrap/application.py +++ b/app/notification/webapi/bootstrap/application.py @@ -2,12 +2,12 @@ import logging from fastapi import FastAPI from fastapi.openapi.utils import get_openapi -from webapi.providers import common -from webapi.providers import logger -from webapi.providers import router -from webapi.providers import database -from webapi.providers import scheduler -from webapi.providers import exception_handler +from app.notification.webapi.providers import common +from app.notification.webapi.providers import logger +from app.notification.webapi.providers import router +from app.notification.webapi.providers import database +from app.notification.webapi.providers import scheduler +from app.notification.webapi.providers import exception_handler from .freeleaps_app import FreeleapsApp @@ -49,11 +49,7 @@ def customize_openapi_security(app: FastAPI) -> None: # Add security scheme to components openapi_schema["components"]["securitySchemes"] = { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - } + "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} } # Add security requirement globally diff --git a/app/notification/webapi/bootstrap/freeleaps_app.py b/app/notification/webapi/bootstrap/freeleaps_app.py index c055dd0..0405322 100644 --- a/app/notification/webapi/bootstrap/freeleaps_app.py +++ b/app/notification/webapi/bootstrap/freeleaps_app.py @@ -1,8 +1,8 @@ from fastapi import FastAPI -from backend.infra.rabbitmq.async_subscriber import AsyncMQSubscriber -from backend.models.constants import NotificationChannel -from webapi.utils.email_consumer import EmailMQConsumer -from webapi.utils.sms_consumer import SmsMQConsumer +from app.notification.backend.infra.rabbitmq.async_subscriber import AsyncMQSubscriber +from app.notification.backend.models.constants import NotificationChannel +from app.notification.webapi.utils.email_consumer import EmailMQConsumer +from app.notification.webapi.utils.sms_consumer import SmsMQConsumer class FreeleapsApp(FastAPI): diff --git a/app/notification/webapi/main.py b/app/notification/webapi/main.py index df0c604..abda1ed 100755 --- a/app/notification/webapi/main.py +++ b/app/notification/webapi/main.py @@ -1,15 +1,13 @@ -from webapi.bootstrap.application import create_app -from webapi.config.site_settings import site_settings +from app.notification.webapi.bootstrap.application import create_app +from app.notification.webapi.config.site_settings import site_settings from fastapi.responses import RedirectResponse -from fastapi.middleware.cors import CORSMiddleware -from strawberry.fastapi import GraphQLRouter -from strawberry.fastapi.handlers import GraphQLTransportWSHandler, GraphQLWSHandler import uvicorn from typing import Any app = create_app() + @app.get("/", status_code=301) async def root(): """ @@ -19,12 +17,16 @@ async def root(): if __name__ == "__main__": - uvicorn.run(app="main:app", host=site_settings.SERVER_HOST, port=site_settings.SERVER_PORT) + uvicorn.run( + app="main:app", host=site_settings.SERVER_HOST, port=site_settings.SERVER_PORT + ) + def get_context() -> Any: # Define your context function. This is where you can set up authentication, database connections, etc. return {} + def get_root_value() -> Any: # Define your root value function. This can be used to customize the root value for GraphQL operations. - return {} \ No newline at end of file + return {} diff --git a/app/notification/webapi/providers/common.py b/app/notification/webapi/providers/common.py index 1dd849f..ae41211 100644 --- a/app/notification/webapi/providers/common.py +++ b/app/notification/webapi/providers/common.py @@ -1,5 +1,5 @@ from fastapi.middleware.cors import CORSMiddleware -from webapi.config.site_settings import site_settings +from app.notification.webapi.config.site_settings import site_settings def register(app): diff --git a/app/notification/webapi/providers/database.py b/app/notification/webapi/providers/database.py index 59ed3ab..0b378a5 100644 --- a/app/notification/webapi/providers/database.py +++ b/app/notification/webapi/providers/database.py @@ -1,4 +1,4 @@ -from webapi.config.site_settings import site_settings +from app.notification.webapi.config.site_settings import site_settings from beanie import init_beanie from motor.motor_asyncio import AsyncIOMotorClient @@ -13,5 +13,5 @@ def register(app): async def initiate_database(): - #init your database here + # init your database here pass diff --git a/app/notification/webapi/providers/logger.py b/app/notification/webapi/providers/logger.py index 4a3f1e7..0b37c27 100644 --- a/app/notification/webapi/providers/logger.py +++ b/app/notification/webapi/providers/logger.py @@ -1,7 +1,7 @@ import logging import sys from loguru import logger -from common.config.log_settings import log_settings +from infra.config.log_settings import log_settings def register(app=None): @@ -21,15 +21,8 @@ def register(app=None): logging.getLogger(name).propagate = True # configure loguru - logger.add( - sink=sys.stdout - ) - logger.add( - sink=file_path, - level=level, - retention=retention, - rotation=rotation - ) + logger.add(sink=sys.stdout) + logger.add(sink=file_path, level=level, retention=retention, rotation=rotation) logger.disable("pika.adapters") logger.disable("pika.connection") diff --git a/app/notification/webapi/providers/router.py b/app/notification/webapi/providers/router.py index 3ad11ae..806b0d0 100644 --- a/app/notification/webapi/providers/router.py +++ b/app/notification/webapi/providers/router.py @@ -1,4 +1,4 @@ -from webapi.routes import api_router +from app.notification.webapi.routes import api_router from starlette import routing diff --git a/app/notification/webapi/providers/scheduler.py b/app/notification/webapi/providers/scheduler.py index 7ea8d6c..39a858d 100644 --- a/app/notification/webapi/providers/scheduler.py +++ b/app/notification/webapi/providers/scheduler.py @@ -4,5 +4,5 @@ import asyncio def register(app): @app.on_event("startup") async def start_scheduler(): - #create your scheduler here + # create your scheduler here pass diff --git a/app/notification/webapi/routes/__init__.py b/app/notification/webapi/routes/__init__.py index 3237813..75ec0ac 100644 --- a/app/notification/webapi/routes/__init__.py +++ b/app/notification/webapi/routes/__init__.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from .send_notification import router as sn_router api_router = APIRouter() - +api_router.include_router(sn_router, tags=["notification"]) websocket_router = APIRouter() diff --git a/app/notification/webapi/routes/send_notification.py b/app/notification/webapi/routes/send_notification.py index 251c81f..afb2d70 100644 --- a/app/notification/webapi/routes/send_notification.py +++ b/app/notification/webapi/routes/send_notification.py @@ -22,14 +22,14 @@ class NotificationRequest(BaseModel): # Web API # Send notification to user for a certain channel @router.post( - "/enqueue_notification", - operation_id="enqueue_notification", + "/send_notification", + operation_id="send_notification", summary="Send notification to user for a certain channel", description="Send notification to user for a channel (e.g., sms, email, in-app, etc.)", response_description="Success/failure response in processing the notification send request", ) # API route to enqueue notifications -@router.post("/enqueue_notification") +@router.post("/send_notification") async def enqueue_notification(request: NotificationRequest): try: notification_hub = NotificationHub() diff --git a/app/notification/webapi/utils/email_consumer.py b/app/notification/webapi/utils/email_consumer.py index 140b76d..760383f 100644 --- a/app/notification/webapi/utils/email_consumer.py +++ b/app/notification/webapi/utils/email_consumer.py @@ -1,6 +1,6 @@ from app.notification.common.config.app_settings import app_settings -from backend.infra.rabbitmq.async_subscriber import AsyncMQSubscriber -from backend.infra.email_handler import EmailHandler +from app.notification.backend.infra.rabbitmq.async_subscriber import AsyncMQSubscriber +from app.notification.backend.infra.email_handler import EmailHandler class EmailMQConsumer: diff --git a/app/notification/webapi/utils/sms_consumer.py b/app/notification/webapi/utils/sms_consumer.py index b27664a..53444d0 100644 --- a/app/notification/webapi/utils/sms_consumer.py +++ b/app/notification/webapi/utils/sms_consumer.py @@ -1,6 +1,6 @@ from app.notification.common.config.app_settings import app_settings -from backend.infra.rabbitmq.async_subscriber import AsyncMQSubscriber -from backend.infra.sms_handler import SmsHandler +from app.notification.backend.infra.rabbitmq.async_subscriber import AsyncMQSubscriber +from app.notification.backend.infra.sms_handler import SmsHandler from infra.log.module_logger import ModuleLogger diff --git a/docker-compose.yaml b/docker-compose.yaml index c13e6fe..53d5d38 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -43,6 +43,20 @@ services: volumes: - .:/app # Mount the current directory to /app in the container + notification: + build: + context: app/notification + dockerfile: Dockerfile + restart: always + ports: + - "8003:8003" # Map the central_storage service port + networks: + - freeleaps_service_hub_network + env_file: + - sites/notification/.env + volumes: + - .:/app # Mount the current directory to /app in the container + networks: freeleaps_service_hub_network: driver: bridge