diff --git a/.env.example b/.env.example
index 0143d34..bb6f6b2 100644
--- a/.env.example
+++ b/.env.example
@@ -49,4 +49,8 @@ FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib
CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
# Firebase
-FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json
\ No newline at end of file
+FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json
+
+# Resend Email Configuration
+RESEND_API_KEY=
+EMAIL_FROM=
\ No newline at end of file
diff --git a/app/core/config.py b/app/core/config.py
index 00efe31..b04e3ef 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -75,6 +75,10 @@ class Settings(BaseSettings):
FACE_ENCRYPTION_KEY: str
FIREBASE_CREDENTIALS_PATH: str
+ # Resend Email Configuration
+ RESEND_API_KEY: str = ""
+ EMAIL_FROM: str = "onboarding@resend.dev"
+
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
diff --git a/app/infra/email.py b/app/infra/email.py
new file mode 100644
index 0000000..b2beaf5
--- /dev/null
+++ b/app/infra/email.py
@@ -0,0 +1,61 @@
+import json
+import urllib.request
+import urllib.error
+import asyncio
+from app.core.config import settings
+from app.core.logger import logger
+
+class EmailSender:
+ @staticmethod
+ async def send_otp_email(to_email: str, otp: str) -> bool:
+ if not settings.RESEND_API_KEY:
+ logger.warning("RESEND_API_KEY is not set. Skipping email sending.")
+ # During development without an API key, just log the OTP
+ logger.info("MOCK EMAIL to %s: Your OTP is %s", to_email, otp)
+ return True
+
+ url = "https://api.resend.com/emails"
+ headers = {
+ "Authorization": f"Bearer {settings.RESEND_API_KEY}",
+ "Content-Type": "application/json",
+ "User-Agent": "multAI-Backend/1.0"
+ }
+
+ html_content = f"""
+
+
Bienvenue sur multAI !
+
Voici votre code de vérification :
+
{otp}
+
Ce code est valide pendant 10 minutes.
+
+ """
+
+ data = {
+ "from": settings.EMAIL_FROM,
+ "to": [to_email],
+ "subject": "Votre code de vérification multAI",
+ "html": html_content
+ }
+
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(data).encode("utf-8"),
+ headers=headers,
+ method="POST"
+ )
+
+ def _send() -> bool:
+ try:
+ with urllib.request.urlopen(req, timeout=10) as response:
+ res_body = response.read()
+ logger.info("Email sent via Resend. Response: %s", res_body)
+ return True
+ except urllib.error.HTTPError as e:
+ err_body = e.read()
+ logger.error("Failed to send email via Resend: %s - %s", e.code, err_body)
+ return False
+ except Exception as e:
+ logger.error("Error sending email: %s", str(e))
+ return False
+
+ return await asyncio.to_thread(_send)
diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py
index 8a9a1a0..e537899 100644
--- a/app/router/mobile/auth.py
+++ b/app/router/mobile/auth.py
@@ -11,11 +11,12 @@
from app.schema.request.mobile.auth import (
MobileLoginRequest,
MobileRegisterRequest,
+ RegisterVerifyRequest,
RefreshTokenRequest,
UpdateDeviceTokenRequest,
InactivateDeviceRequest,
)
-from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema
+from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema, RegisterPendingResponse
router = APIRouter(prefix="/auth")
@@ -33,18 +34,29 @@ def _get_client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
-@router.post("/register", response_model=MobileAuthResponse)
+@router.post("/register", response_model=RegisterPendingResponse)
async def mobile_register(
req: MobileRegisterRequest,
request: Request,
container: Container = Depends(get_container),
-) -> MobileAuthResponse:
+) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip)
+ return result
+
+
+@router.post("/register/verify", response_model=MobileAuthResponse)
+async def mobile_register_verify(
+ req: RegisterVerifyRequest,
+ request: Request,
+ container: Container = Depends(get_container),
+) -> MobileAuthResponse:
+ client_ip = _get_client_ip(request)
+ result = await container.auth_service.verify_mobile_register(container.redis, req, client_ip=client_ip)
await container.audit_service.create_record(
event_type=AuditEventType.USER_SIGNUP,
user_id=result.user_id,
- metadata={"endpoint": "register"},
+ metadata={"endpoint": "register_verify"},
)
return result
diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py
index b272f2b..ab9c9d6 100644
--- a/app/schema/request/mobile/auth.py
+++ b/app/schema/request/mobile/auth.py
@@ -63,6 +63,10 @@ class MobileLoginRequest(MobileAuthBaseRequest):
pass
+class RegisterVerifyRequest(MobileAuthBaseRequest):
+ otp: str = Field(..., min_length=6, max_length=6, description="The 6-digit OTP code sent via email")
+
+
diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py
index 154f9a4..e03a074 100644
--- a/app/schema/response/mobile/auth.py
+++ b/app/schema/response/mobile/auth.py
@@ -25,6 +25,10 @@ class MeResponse(BaseModel):
sessions: Optional[SessionSchema]
+class RegisterPendingResponse(BaseModel):
+ message: str
+ status: str
+ email: str
class MobileAuthResponse(BaseModel):
access_token: str
diff --git a/app/service/users.py b/app/service/users.py
index 95f0988..9de8180 100644
--- a/app/service/users.py
+++ b/app/service/users.py
@@ -22,8 +22,12 @@
MobileAuthBaseRequest,
MobileLoginRequest,
MobileRegisterRequest,
+ RegisterVerifyRequest,
)
-from app.schema.response.mobile.auth import MobileAuthResponse
+from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse
+from app.infra.nats import NatsClient
+import secrets
+import json
from db.generated import user as user_queries
from db.generated import devices as device_queries
from db.generated import session as session_queries
@@ -131,7 +135,7 @@ async def mobile_register(
redis: RedisClient,
req: MobileRegisterRequest,
client_ip: Optional[str] = None,
- ) -> MobileAuthResponse:
+ ) -> RegisterPendingResponse:
logger.info("mobile_register attempt")
max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS
window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS
@@ -154,16 +158,61 @@ async def mobile_register(
if existing_user is not None:
logger.warning("register attempt: email_already_in_use")
raise AppException.conflict("Email already in use; please login instead")
+
hashed = hash_password(req.password)
- logger.info("register attempt: creating_new_user")
+ otp = "".join(secrets.choice("0123456789") for _ in range(6))
+
+ pending_key = f"pending_user:{req.email}"
+ pending_data = {
+ "hashed_password": hashed,
+ }
+
+ # Save in Redis for 10 minutes (600 seconds)
+ await redis.set(pending_key, json.dumps(pending_data), expire=600)
+ await redis.set(f"otp:{req.email}", otp, expire=600)
+
+ # Send to NATS
+ await NatsClient.publish("email.send_otp", json.dumps({"email": req.email, "otp": otp}).encode("utf-8"))
+
+ logger.info("register success, OTP sent to %s", req.email)
+ return RegisterPendingResponse(
+ message="OTP sent to email",
+ status="pending_verification",
+ email=req.email
+ )
+
+ async def verify_mobile_register(
+ self,
+ redis: RedisClient,
+ req: RegisterVerifyRequest,
+ client_ip: Optional[str] = None,
+ ) -> MobileAuthResponse:
+ otp_key = f"otp:{req.email}"
+ stored_otp = await redis.get(otp_key)
+
+ if not stored_otp or stored_otp != req.otp:
+ raise AppException.unauthorized("Invalid or expired OTP")
+
+ pending_key = f"pending_user:{req.email}"
+ raw_data = await redis.get(pending_key)
+ if not raw_data:
+ raise AppException.unauthorized("Registration session expired")
+
+ data = json.loads(raw_data)
+
try:
- user = await self.user_querier.create_user(email=req.email, hashed_password=hashed)
+ user = await self.user_querier.create_user(email=req.email, hashed_password=data["hashed_password"])
if not user:
raise AppException.internal_error("Failed to create user")
except SQLAlchemyError as exc:
logger.error("Failed to create user: %s", exc)
raise DBException.handle(exc)
- logger.info("register success user_id=%s", user.id)
+
+ # Clean up redis
+ await redis.delete(otp_key)
+ await redis.delete(pending_key)
+
+ logger.info("register verify success user_id=%s", user.id)
return await self._create_mobile_session(
redis=redis,
user=user,
diff --git a/app/worker/email_worker/main.py b/app/worker/email_worker/main.py
new file mode 100644
index 0000000..129d1ab
--- /dev/null
+++ b/app/worker/email_worker/main.py
@@ -0,0 +1,62 @@
+import asyncio
+import json
+
+from app.core.config import settings
+from app.core.logger import logger
+from app.infra.nats import NatsClient
+from app.infra.email import EmailSender
+
+
+async def handle_message(raw_payload: bytes | str) -> None:
+ try:
+ if isinstance(raw_payload, bytes):
+ raw_payload = raw_payload.decode()
+
+ payload = json.loads(raw_payload)
+ email = payload.get("email")
+ otp = payload.get("otp")
+
+ if not email or not otp:
+ logger.error("Invalid email.send_otp payload: %s", raw_payload)
+ return
+
+ success = await EmailSender.send_otp_email(to_email=email, otp=otp)
+ if success:
+ logger.info("Successfully sent OTP email to %s", email)
+ else:
+ logger.error("Failed to send OTP email to %s", email)
+
+ except Exception:
+ logger.exception("Unexpected error in email worker")
+
+
+async def run_worker() -> None:
+ logger.info("Email worker started")
+
+ async def wrapped_handler(msg: bytes | str) -> None:
+ await handle_message(msg)
+
+ # Subscribe to the email.send_otp subject
+ await NatsClient.subscribe("email.send_otp", wrapped_handler)
+
+ # Keep the worker running
+ await asyncio.Event().wait()
+
+
+async def main() -> None:
+ await NatsClient.connect(
+ host=settings.NATS_HOST,
+ port=settings.NATS_PORT,
+ user=settings.NATS_USER,
+ password=settings.NATS_PASSWORD,
+ )
+
+ try:
+ await run_worker()
+ finally:
+ await NatsClient.close()
+ logger.info("Email Worker shutdown")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/docker-compose.staging.local.yml b/docker-compose.staging.local.yml
index 135eb75..0d3c378 100644
--- a/docker-compose.staging.local.yml
+++ b/docker-compose.staging.local.yml
@@ -14,5 +14,13 @@ services:
dockerfile: Dockerfile
pull_policy: never
+ email-worker:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ pull_policy: never
+ volumes:
+ - ./:/app
+
volumes:
insightface_cache:
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
index be3ff4c..93435d8 100644
--- a/docker-compose.staging.yml
+++ b/docker-compose.staging.yml
@@ -96,6 +96,19 @@ services:
networks:
- multi_network
+ email-worker:
+ image: ghcr.io/microclub-usthb/multai-back:latest
+ container_name: multi_email_worker
+ restart: unless-stopped
+ env_file:
+ - .env.staging
+ depends_on:
+ - nats
+ - redis
+ command: ["uv", "run", "python", "-m", "app.worker.email_worker.main"]
+ networks:
+ - multi_network
+
volumes:
postgres_data:
minio_data:
diff --git a/pyproject.toml b/pyproject.toml
index d615333..2fca281 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ dependencies = [
"opencv-python>=4.13.0.92",
"filetype>=1.2.0",
"pillow-heif>=1.3.0",
+ "sqlalchemy>=2.0.47",
]
[tool.ruff]
diff --git a/tests/unit/test_auth_email_otp.py b/tests/unit/test_auth_email_otp.py
new file mode 100644
index 0000000..2eb5af4
--- /dev/null
+++ b/tests/unit/test_auth_email_otp.py
@@ -0,0 +1,128 @@
+import uuid
+import json
+from unittest.mock import AsyncMock, patch
+import pytest
+
+from app.service.users import AuthService
+from app.schema.request.mobile.auth import MobileRegisterRequest, RegisterVerifyRequest
+
+@pytest.fixture
+def mock_user_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_device_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_session_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_face_embedding_service() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_redis() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def auth_service(
+ mock_user_querier: AsyncMock,
+ mock_device_querier: AsyncMock,
+ mock_session_querier: AsyncMock,
+ mock_face_embedding_service: AsyncMock,
+) -> AuthService:
+ return AuthService(
+ user_querier=mock_user_querier,
+ device_querier=mock_device_querier,
+ session_querier=mock_session_querier,
+ face_embedding_service=mock_face_embedding_service,
+ )
+
+@pytest.mark.asyncio
+@patch("app.service.users.NatsClient.publish")
+async def test_mobile_register_sends_otp(
+ mock_publish: AsyncMock,
+ auth_service: AuthService,
+ mock_redis: AsyncMock,
+ mock_user_querier: AsyncMock,
+) -> None:
+ # Arrange
+ req = MobileRegisterRequest(
+ email="test@example.com",
+ password="Password1!",
+ device_name="iPhone",
+ device_type="iOS",
+ device_id=uuid.uuid4(),
+ )
+ mock_user_querier.get_user_by_email.return_value = None # User does not exist
+ mock_redis.incr.return_value = 1 # Rate limit check passes
+
+ # Act
+ res = await auth_service.mobile_register(redis=mock_redis, req=req)
+
+ # Assert
+ assert res.status == "pending_verification"
+ assert res.email == "test@example.com"
+
+ # Verify redis was called to save pending user and OTP
+ assert mock_redis.set.call_count == 2
+
+ # Verify NATS publish was called
+ mock_publish.assert_called_once()
+ args, _ = mock_publish.call_args
+ assert args[0] == "email.send_otp"
+ payload = json.loads(args[1])
+ assert payload["email"] == "test@example.com"
+ assert "otp" in payload
+
+@pytest.mark.asyncio
+async def test_verify_mobile_register_success(
+ auth_service: AuthService,
+ mock_redis: AsyncMock,
+ mock_user_querier: AsyncMock,
+ mock_device_querier: AsyncMock,
+ mock_session_querier: AsyncMock,
+) -> None:
+ # Arrange
+ device_id = uuid.uuid4()
+ req = RegisterVerifyRequest(
+ email="test@example.com",
+ password="Password1!",
+ device_name="iPhone",
+ device_type="iOS",
+ device_id=device_id,
+ otp="123456"
+ )
+
+ mock_redis.get.side_effect = [
+ "123456", # First call gets OTP
+ json.dumps({"hashed_password": "hashed_pass"}) # Second call gets pending user
+ ]
+
+ mock_user = AsyncMock()
+ mock_user.id = uuid.uuid4()
+ mock_user.email = "test@example.com"
+ mock_user.blocked = False
+ mock_user_querier.create_user.return_value = mock_user
+
+ mock_session_querier.count_user_sessions.return_value = 0
+ mock_session = AsyncMock()
+ mock_session.id = uuid.uuid4()
+ mock_session_querier.upsert_session.return_value = mock_session
+ mock_device_querier.get_device_by_id.return_value = None
+
+ # Act
+ with patch("app.service.users.SessionService.cache_session_for_auth", new_callable=AsyncMock):
+ res = await auth_service.verify_mobile_register(redis=mock_redis, req=req)
+
+ # Assert
+ assert res.is_new_user is True
+ assert res.user_id == mock_user.id
+
+ # Verify user was created
+ mock_user_querier.create_user.assert_called_once_with(email="test@example.com", hashed_password="hashed_pass")
+
+ # Verify redis cleanup
+ assert mock_redis.delete.call_count == 2
diff --git a/uv.lock b/uv.lock
index 48310d2..f7b0008 100644
--- a/uv.lock
+++ b/uv.lock
@@ -326,6 +326,7 @@ dependencies = [
{ name = "pywebpush" },
{ name = "redis" },
{ name = "setuptools" },
+ { name = "sqlalchemy" },
]
[package.dev-dependencies]
@@ -365,6 +366,7 @@ requires-dist = [
{ name = "pywebpush", specifier = ">=2.3.0" },
{ name = "redis", specifier = ">=7.2.1" },
{ name = "setuptools", specifier = ">=82.0.0" },
+ { name = "sqlalchemy", specifier = ">=2.0.47" },
]
[package.metadata.requires-dev]