From 03a63688caf886f8f2b49e3153f41b8152bbd90d Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Mon, 6 Jul 2026 13:51:32 +0530 Subject: [PATCH 1/5] feat(email): integrate Resend email service and email infrastructure (ENG-25) --- docker-compose.yml | 8 ++ pyproject.toml | 1 + .../core/config.py | 20 ++++- .../routers/auth.py | 46 +++++++--- .../services/email_service.py | 33 +++++++ uv.lock | 87 +++++++++++++++++++ 6 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 src/ai_trading_discipline_copilot/services/email_service.py diff --git a/docker-compose.yml b/docker-compose.yml index 4eac8c6..d4722b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,14 +11,22 @@ services: - APP_VERSION=0.1.0 - APP_ENV=development - DEBUG=true + - SECRET_KEY=${SECRET_KEY} - ALGORITHM=HS256 - ACCESS_TOKEN_EXPIRE_MINUTES=30 + - DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@db:5432/trading_db + - AI_PROVIDER=openai - OPENAI_API_KEY=${OPENAI_API_KEY:-} - AI_MODEL=gpt-4o + - ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"] + + - RESEND_API_KEY=${RESEND_API_KEY} + - EMAIL_FROM=${EMAIL_FROM} + - FRONTEND_URL=${FRONTEND_URL} depends_on: db: condition: service_healthy diff --git a/pyproject.toml b/pyproject.toml index 8ea1a12..b6a6a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.28.1", "pydantic-settings>=2.14.2", "pyjwt[crypto]>=2.13.0", + "resend>=2.32.2", "sqlalchemy>=2.0.51", ] diff --git a/src/ai_trading_discipline_copilot/core/config.py b/src/ai_trading_discipline_copilot/core/config.py index c867cc4..238b37d 100644 --- a/src/ai_trading_discipline_copilot/core/config.py +++ b/src/ai_trading_discipline_copilot/core/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): refresh_token_expire_days: int = 7 bcrypt_rounds: int = 12 cookie_name: str = "refresh_token" + # Defaults True so cookies always require HTTPS outside local dev. # Override with COOKIE_SECURE=false in .env for local development only. cookie_secure: bool = True @@ -55,8 +56,16 @@ class Settings(BaseSettings): openai_api_key: SecretStr = SecretStr("") ai_model: str = "gpt-4o" + # Email + resend_api_key: SecretStr + email_from: str + frontend_url: str + # CORS - allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"] + allowed_origins: list[str] = [ + "http://localhost:3000", + "http://localhost:8000", + ] @field_validator("secret_key") @classmethod @@ -69,24 +78,31 @@ def secret_key_min_length(cls, v: SecretStr) -> SecretStr: @classmethod def validate_database_url(cls, v: SecretStr) -> SecretStr: url_str = v.get_secret_value() + if not url_str.startswith("postgresql+asyncpg://"): raise ValueError("database_url must start with 'postgresql+asyncpg://'") + from pydantic import TypeAdapter try: TypeAdapter(PostgresDsn).validate_python(url_str) except Exception as e: raise ValueError(f"Invalid database URL format: {e}") from None + return v @field_validator("allowed_origins") @classmethod - def validate_allowed_origins(cls, v: list[str]) -> list[str]: + def validate_allowed_origins( + cls, + v: list[str], + ) -> list[str]: if "*" in v: raise ValueError( "Wildcard '*' is not allowed in allowed_origins " "when credentials are enabled" ) + return v diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index 4df755f..58dd683 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -12,12 +12,12 @@ from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession -from ai_trading_discipline_copilot.core.config import get_settings from ai_trading_discipline_copilot.core.dependencies import ( get_current_user, get_db, ) from ai_trading_discipline_copilot.core.exceptions import ( + ForbiddenException, NotFoundException, UnauthorizedException, ) @@ -29,6 +29,7 @@ UserSessionResponse, ) from ai_trading_discipline_copilot.services.auth_service import AuthService +from ai_trading_discipline_copilot.services.email_service import EmailService from ai_trading_discipline_copilot.services.refresh_token_service import ( RefreshTokenService, ) @@ -37,8 +38,6 @@ if TYPE_CHECKING: from ai_trading_discipline_copilot.models.user import User -settings = get_settings() - router = APIRouter( prefix="/auth", tags=["Authentication"], @@ -133,7 +132,7 @@ async def refresh( ) -> Token: """Rotate the refresh token and issue a new access token.""" - refresh_token = request.cookies.get(settings.cookie_name) + refresh_token = request.cookies.get("refresh_token") if refresh_token is None: raise UnauthorizedException("Missing refresh token") @@ -185,11 +184,9 @@ async def logout( response: Response, db: Annotated[AsyncSession, Depends(get_db)], ) -> None: - """Log out the current session by revoking the refresh token - and deleting the cookie. - """ + """Log out the current session by revoking the refresh token and deleting the cookie.""" # noqa: E501 - refresh_token = request.cookies.get(settings.cookie_name) + refresh_token = request.cookies.get("refresh_token") await AuthService.logout( response=response, db=db, @@ -226,7 +223,7 @@ async def get_sessions( ) -> list[UserSessionResponse]: """Get all active sessions for the authenticated user.""" - refresh_token = request.cookies.get(settings.cookie_name) + refresh_token = request.cookies.get("refresh_token") current_jti = None if refresh_token: payload = decode_refresh_token(refresh_token) @@ -265,7 +262,7 @@ async def revoke_session( ) -> None: """Revoke a specific session for the user.""" - refresh_token = request.cookies.get(settings.cookie_name) + refresh_token = request.cookies.get("refresh_token") session = await RefreshTokenService.get_by_id(db=db, session_id=session_id) if not session or session.user_id != current_user.id: raise NotFoundException("Session not found") @@ -277,3 +274,32 @@ async def revoke_session( payload = decode_refresh_token(refresh_token) if payload and payload.get("jti") == session.jti: AuthService.delete_refresh_cookie(response) + + +@router.post( + "/cleanup", + status_code=status.HTTP_200_OK, +) +async def cleanup_sessions( + current_user: Annotated["User", Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> dict[str, int]: + """Clean up expired sessions from the database (Admin only).""" + from ai_trading_discipline_copilot.models.user import UserRole + + if current_user.role != UserRole.ADMIN: + raise ForbiddenException("Only admins can perform session cleanup") + + deleted_count = await RefreshTokenService.cleanup_expired_sessions(db=db) + return {"deleted_sessions": deleted_count} + + +@router.post("/test-email", status_code=204) +async def test_email() -> None: + """Temporary endpoint to verify Resend integration.""" + + await EmailService.send_email( + to="sreenandpk3@gmail.com", + subject="Resend Test", + html="

Hello from AI Trading Discipline Copilot 🚀

", + ) diff --git a/src/ai_trading_discipline_copilot/services/email_service.py b/src/ai_trading_discipline_copilot/services/email_service.py new file mode 100644 index 0000000..306f610 --- /dev/null +++ b/src/ai_trading_discipline_copilot/services/email_service.py @@ -0,0 +1,33 @@ +"""Email service powered by Resend.""" + +from __future__ import annotations + +import resend + +from ai_trading_discipline_copilot.core.config import get_settings + +settings = get_settings() + +resend.api_key = settings.resend_api_key.get_secret_value() + + +class EmailService: + """Business logic for sending emails.""" + + @staticmethod + async def send_email( + *, + to: str, + subject: str, + html: str, + ) -> None: + """Send an email using Resend.""" + + resend.Emails.send( + { + "from": settings.email_from, + "to": to, + "subject": subject, + "html": html, + } + ) diff --git a/uv.lock b/uv.lock index 47369d7..3c2396c 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,7 @@ dependencies = [ { name = "httpx" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, + { name = "resend" }, { name = "sqlalchemy" }, ] @@ -43,6 +44,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.13.0" }, + { name = "resend", specifier = ">=2.32.2" }, { name = "sqlalchemy", specifier = ">=2.0.51" }, ] @@ -302,6 +304,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -1238,6 +1297,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "resend" +version = "2.32.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/d5/2c46334104cce931170d756c7478263744b3eb168179c329d90a065f9b6b/resend-2.32.2.tar.gz", hash = "sha256:448001810f32e7aea39bab27318263fdc69f5764be16b7c6241ed7714dc07ea3", size = 46202, upload-time = "2026-06-17T19:47:28.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/e9/64b7f15e4d2b9bc363e1a6e0abc518f7aea4b8dfeab8d15a25e508c31fb2/resend-2.32.2-py2.py3-none-any.whl", hash = "sha256:1a8df5ef54b3a5cb7bb18b745f85fda07f1fa8f79b83dd9a25f6e37fbd625ef2", size = 72664, upload-time = "2026-06-17T19:47:26.915Z" }, +] + [[package]] name = "rich" version = "15.0.0" From 6f4b169b6d8d5f0d7fa04ce03524f036c7cee0f0 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Mon, 6 Jul 2026 17:50:04 +0530 Subject: [PATCH 2/5] feat(auth): implement password reset with Resend email support --- ...2c29db6_add_password_reset_tokens_table.py | 44 ++ .../models/__init__.py | 7 +- .../models/password_reset_token.py | 62 +++ .../models/user.py | 7 +- .../routers/auth.py | 202 ++++++- .../schemas/__init__.py | 10 + .../schemas/password_reset.py | 29 + .../services/__init__.py | 7 +- .../services/password_reset_service.py | 212 +++++++ tests/test_password_reset.py | 515 ++++++++++++++++++ 10 files changed, 1091 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py create mode 100644 src/ai_trading_discipline_copilot/models/password_reset_token.py create mode 100644 src/ai_trading_discipline_copilot/schemas/password_reset.py create mode 100644 src/ai_trading_discipline_copilot/services/password_reset_service.py create mode 100644 tests/test_password_reset.py diff --git a/alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py b/alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py new file mode 100644 index 0000000..832a859 --- /dev/null +++ b/alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py @@ -0,0 +1,44 @@ +"""add_password_reset_tokens_table + +Revision ID: 3ead82c29db6 +Revises: 21fa4ae8b1cc +Create Date: 2026-07-06 15:40:14.276063 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3ead82c29db6' +down_revision: Union[str, Sequence[str], None] = '21fa4ae8b1cc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('password_reset_tokens', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens') + op.drop_table('password_reset_tokens') + # ### end Alembic commands ### diff --git a/src/ai_trading_discipline_copilot/models/__init__.py b/src/ai_trading_discipline_copilot/models/__init__.py index c5bb8ea..ea93748 100644 --- a/src/ai_trading_discipline_copilot/models/__init__.py +++ b/src/ai_trading_discipline_copilot/models/__init__.py @@ -1,6 +1,11 @@ # SQLAlchemy ORM models # Contains: base, user, trade, journal, psychology +from .password_reset_token import PasswordResetToken from .refresh_token import RefreshToken from .user import User -__all__ = ["User", "RefreshToken"] +__all__ = [ + "User", + "RefreshToken", + "PasswordResetToken", +] diff --git a/src/ai_trading_discipline_copilot/models/password_reset_token.py b/src/ai_trading_discipline_copilot/models/password_reset_token.py new file mode 100644 index 0000000..c248e06 --- /dev/null +++ b/src/ai_trading_discipline_copilot/models/password_reset_token.py @@ -0,0 +1,62 @@ +"""Password reset token model.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ai_trading_discipline_copilot.core.database import Base + +if TYPE_CHECKING: + from .user import User + + +class PasswordResetToken(Base): + """Stores one-time password reset tokens.""" + + __tablename__ = "password_reset_tokens" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + token_hash: Mapped[str] = mapped_column( + String(64), + nullable=False, + unique=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC) + timedelta(minutes=15), + nullable=False, + ) + + used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + user: Mapped[User] = relationship( + back_populates="password_reset_tokens", + ) diff --git a/src/ai_trading_discipline_copilot/models/user.py b/src/ai_trading_discipline_copilot/models/user.py index 37b5b77..1329b98 100644 --- a/src/ai_trading_discipline_copilot/models/user.py +++ b/src/ai_trading_discipline_copilot/models/user.py @@ -13,6 +13,8 @@ relationship, ) +if TYPE_CHECKING: + from .password_reset_token import PasswordResetToken from ai_trading_discipline_copilot.core.database import Base if TYPE_CHECKING: @@ -60,7 +62,10 @@ class User(Base): default=UserRole.USER, nullable=False, ) - + password_reset_tokens: Mapped[list[PasswordResetToken]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) is_active: Mapped[bool] = mapped_column( Boolean, default=True, diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index 58dd683..841d329 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -1,3 +1,4 @@ +import logging import uuid from typing import TYPE_CHECKING, Annotated @@ -10,8 +11,10 @@ status, ) from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from ai_trading_discipline_copilot.core.config import get_settings from ai_trading_discipline_copilot.core.dependencies import ( get_current_user, get_db, @@ -22,6 +25,13 @@ UnauthorizedException, ) from ai_trading_discipline_copilot.core.security import decode_refresh_token +from ai_trading_discipline_copilot.models.user import User +from ai_trading_discipline_copilot.schemas.password_reset import ( + ForgotPasswordRequest, + ForgotPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, +) from ai_trading_discipline_copilot.schemas.user import ( Token, UserCreate, @@ -30,13 +40,19 @@ ) from ai_trading_discipline_copilot.services.auth_service import AuthService from ai_trading_discipline_copilot.services.email_service import EmailService +from ai_trading_discipline_copilot.services.password_reset_service import ( + PasswordResetService, +) from ai_trading_discipline_copilot.services.refresh_token_service import ( RefreshTokenService, ) from ai_trading_discipline_copilot.services.user_service import UserService if TYPE_CHECKING: - from ai_trading_discipline_copilot.models.user import User + pass + +settings = get_settings() +logger = logging.getLogger(__name__) router = APIRouter( prefix="/auth", @@ -303,3 +319,187 @@ async def test_email() -> None: subject="Resend Test", html="

Hello from AI Trading Discipline Copilot 🚀

", ) + + +async def send_reset_email_task(email: str, reset_url: str, app_name: str) -> None: + """Send a password reset email asynchronously in the background. + + Args: + email: The recipient's email address. + reset_url: The plain-text password reset URL. + app_name: The application name to display. + """ + try: + import html + from datetime import UTC, datetime + + escaped_app_name = html.escape(app_name) + escaped_reset_url = html.escape(reset_url) + current_year = datetime.now(UTC).year + + html_content = f""" + + + + + Reset your password + + + +
+
+ {escaped_app_name} +
+
+

Hello,

+

We received a request to reset the password for your account.

+

Click the button below to set a new password:

+ +

This password reset link is only valid for 15 minutes.

+

If you did not request a password reset, you can safely ignore this email.

+

+ If you're having trouble clicking the button, copy and paste the URL below:
+ {escaped_reset_url} +

+
+ +
+ +""" + + await EmailService.send_email( + to=email, + subject="Reset your password", + html=html_content, + ) + except Exception as e: + logger.exception("Failed to send password reset email to %s: %s", email, e) + + +@router.post( + "/forgot-password", + response_model=ForgotPasswordResponse, + status_code=status.HTTP_200_OK, +) +async def forgot_password( + request_data: ForgotPasswordRequest, + background_tasks: BackgroundTasks, + db: Annotated[AsyncSession, Depends(get_db)], +) -> ForgotPasswordResponse: + """Request a password reset link. + + Generates a password reset token if the email exists, builds a reset link, + and sends a reset email in the background. Always returns a generic success + message to prevent account enumeration. + """ + result = await db.execute( + select(User).where(User.email == request_data.email) + ) + user = result.scalar_one_or_none() + + if user: + plain_token = await PasswordResetService.create_reset_token(db, user) + reset_url = f"{settings.frontend_url}/reset-password?token={plain_token}" + background_tasks.add_task( + send_reset_email_task, + email=user.email, + reset_url=reset_url, + app_name=settings.app_name, + ) + + return ForgotPasswordResponse( + message=( + "If an account with that email exists, " + "a password reset link has been sent." + ) + ) + + +@router.post( + "/reset-password", + response_model=ResetPasswordResponse, + status_code=status.HTTP_200_OK, +) +async def reset_password( + request_data: ResetPasswordRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResetPasswordResponse: + """Reset password using a valid reset token. + + Verifies the token, updates the password, and revokes all active sessions. + """ + try: + await PasswordResetService.reset_password( + db=db, + token=request_data.token, + new_password=request_data.new_password, + ) + except UnauthorizedException as err: + raise UnauthorizedException( + "Invalid or expired password reset token" + ) from err + + return ResetPasswordResponse( + message="Password reset successful." + ) + diff --git a/src/ai_trading_discipline_copilot/schemas/__init__.py b/src/ai_trading_discipline_copilot/schemas/__init__.py index 3f8b224..f55253b 100644 --- a/src/ai_trading_discipline_copilot/schemas/__init__.py +++ b/src/ai_trading_discipline_copilot/schemas/__init__.py @@ -1,8 +1,18 @@ # Pydantic request/response schemas # Contains: user, trade, journal, psychology +from .password_reset import ( + ForgotPasswordRequest, + ForgotPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, +) from .user import UserCreate, UserResponse __all__ = [ "UserCreate", "UserResponse", + "ForgotPasswordRequest", + "ForgotPasswordResponse", + "ResetPasswordRequest", + "ResetPasswordResponse", ] diff --git a/src/ai_trading_discipline_copilot/schemas/password_reset.py b/src/ai_trading_discipline_copilot/schemas/password_reset.py new file mode 100644 index 0000000..5d681d2 --- /dev/null +++ b/src/ai_trading_discipline_copilot/schemas/password_reset.py @@ -0,0 +1,29 @@ +"""Schemas for password reset.""" + +from pydantic import BaseModel, EmailStr, Field + + +class ForgotPasswordRequest(BaseModel): + """Request to initiate password reset.""" + + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + """Request to reset password.""" + + token: str = Field(min_length=32) + new_password: str = Field(min_length=8, max_length=72) + + +class ForgotPasswordResponse(BaseModel): + """Response to forgot password request.""" + + message: str + + +class ResetPasswordResponse(BaseModel): + """Response to reset password execution.""" + + message: str + diff --git a/src/ai_trading_discipline_copilot/services/__init__.py b/src/ai_trading_discipline_copilot/services/__init__.py index e266609..2389252 100644 --- a/src/ai_trading_discipline_copilot/services/__init__.py +++ b/src/ai_trading_discipline_copilot/services/__init__.py @@ -1,5 +1,10 @@ # Business logic services # Contains: auth, trade, journal, psychology, ai_coach +from .password_reset_service import PasswordResetService from .user_service import UserService -__all__ = ["UserService"] +__all__ = [ + "UserService", + "PasswordResetService", +] + diff --git a/src/ai_trading_discipline_copilot/services/password_reset_service.py b/src/ai_trading_discipline_copilot/services/password_reset_service.py new file mode 100644 index 0000000..23a2cdc --- /dev/null +++ b/src/ai_trading_discipline_copilot/services/password_reset_service.py @@ -0,0 +1,212 @@ +"""Password reset service business logic.""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import delete, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException +from ai_trading_discipline_copilot.core.security import hash_password +from ai_trading_discipline_copilot.models.password_reset_token import PasswordResetToken +from ai_trading_discipline_copilot.services.refresh_token_service import ( + RefreshTokenService, +) + +if TYPE_CHECKING: + from ai_trading_discipline_copilot.models.user import User + +logger = logging.getLogger(__name__) + + +class PasswordResetService: + """Business logic for handling password resets.""" + + @staticmethod + def generate_token() -> str: + """Generate a secure random token. + + Returns: + str: A secure random URL-safe string. + """ + return secrets.token_urlsafe(32) + + @staticmethod + def hash_token(token: str) -> str: + """Return the SHA-256 hash of a token. + + Args: + token: The plain-text token. + + Returns: + str: The hex-encoded SHA-256 hash of the token. + """ + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + @staticmethod + async def create_reset_token( + db: AsyncSession, + user: User, + ) -> str: + """Create a new password reset token. + + Generates a token, hashes it, deletes previous unused reset tokens for + the user, inserts a new PasswordResetToken that expires in 15 minutes, + commits the transaction, and returns the plain-text token. + + Args: + db: The database session. + user: The user requesting the reset. + + Returns: + str: The plain-text token. + """ + plain_token = PasswordResetService.generate_token() + token_hash = PasswordResetService.hash_token(plain_token) + + # Delete previous unused reset tokens for the same user + await db.execute( + delete(PasswordResetToken).where( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used_at.is_(None), + ) + ) + + # Insert new PasswordResetToken expiring in 15 minutes + expires_at = datetime.now(UTC) + timedelta(minutes=15) + reset_token = PasswordResetToken( + user_id=user.id, + token_hash=token_hash, + expires_at=expires_at, + ) + db.add(reset_token) + await db.commit() + + logger.info( + "Created password reset token for user ID: %s expiring at %s", + user.id, + expires_at, + ) + + return plain_token + + @staticmethod + async def validate_token( + db: AsyncSession, + token: str, + ) -> PasswordResetToken: + """Validate a password reset token. + + Hashes the incoming token, checks the database, validates that the token + exists, is not expired, and has not been used yet. + + Args: + db: The database session. + token: The plain-text reset token. + + Returns: + PasswordResetToken: The validated token model instance with user loaded. + + Raises: + UnauthorizedException: If the token is invalid, expired, or already used. + """ + token_hash = PasswordResetService.hash_token(token) + stmt = ( + select(PasswordResetToken) + .where(PasswordResetToken.token_hash == token_hash) + .options(selectinload(PasswordResetToken.user)) + ) + result = await db.execute(stmt) + db_token = result.scalar_one_or_none() + + if db_token is None: + logger.warning("Password reset token not found in database.") + raise UnauthorizedException("Invalid or expired password reset token") + + if db_token.used_at is not None: + logger.warning( + "Password reset token already used at %s.", db_token.used_at + ) + raise UnauthorizedException("Invalid or expired password reset token") + + if db_token.expires_at <= datetime.now(UTC): + logger.warning( + "Password reset token expired at %s.", db_token.expires_at + ) + raise UnauthorizedException("Invalid or expired password reset token") + + return db_token + + @staticmethod + async def reset_password( + db: AsyncSession, + token: str, + new_password: str, + ) -> None: + """Reset the user's password using the provided reset token. + + Validates the token, loads the user, hashes the new password, updates + the user's password hash, marks the token as used, and revokes all active + refresh sessions for the user. + + Args: + db: The database session. + token: The plain-text reset token. + new_password: The new plain-text password. + + Raises: + UnauthorizedException: If the token is invalid or expired. + """ + db_token = await PasswordResetService.validate_token(db, token) + user = db_token.user + + # Hash new password + hashed = hash_password(new_password) + user.hashed_password = hashed + + # Mark token as used + db_token.used_at = datetime.now(UTC) + + # Revoke every active refresh session + await RefreshTokenService.revoke_all_for_user(db, user.id) + + await db.commit() + logger.info( + "Successfully reset password and revoked all sessions for user ID: %s", + user.id, + ) + + @staticmethod + async def cleanup_expired_tokens( + db: AsyncSession, + ) -> int: + """Delete all expired or already-used password reset tokens. + + Args: + db: The database session. + + Returns: + int: The number of deleted tokens. + """ + from sqlalchemy.engine import CursorResult + + stmt = delete(PasswordResetToken).where( + or_( + PasswordResetToken.expires_at <= datetime.now(UTC), + PasswordResetToken.used_at.is_not(None), + ) + ) + result: CursorResult[tuple[()]] = await db.execute(stmt) # type: ignore[assignment] + await db.commit() + + deleted_count: int = result.rowcount or 0 + logger.info( + "Cleaned up %d expired or used password reset tokens", deleted_count + ) + return deleted_count diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py new file mode 100644 index 0000000..033469c --- /dev/null +++ b/tests/test_password_reset.py @@ -0,0 +1,515 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException +from ai_trading_discipline_copilot.core.security import hash_password, verify_password +from ai_trading_discipline_copilot.models.password_reset_token import PasswordResetToken +from ai_trading_discipline_copilot.models.refresh_token import RefreshToken +from ai_trading_discipline_copilot.models.user import User +from ai_trading_discipline_copilot.services.password_reset_service import ( + PasswordResetService, +) +from ai_trading_discipline_copilot.services.refresh_token_service import ( + RefreshTokenService, +) + +TEST_PASSWORD = "OldPassword1!" # noqa: S105 +NEW_TEST_PASSWORD = "NewPassword2?" # noqa: S105 + + +@pytest.fixture +async def test_user(db_session: AsyncSession) -> User: + """Create a test user in the database.""" + user = User( + username="resetuser", + email="resetuser@example.com", + hashed_password=hash_password(TEST_PASSWORD), + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.mark.anyio +async def test_generate_and_hash_token() -> None: + """Test generating a plain-text token and hashing it.""" + plain = PasswordResetService.generate_token() + assert isinstance(plain, str) + assert len(plain) >= 32 + + hashed = PasswordResetService.hash_token(plain) + assert isinstance(hashed, str) + assert len(hashed) == 64 # SHA-256 hex digest is 64 chars + + +@pytest.mark.anyio +async def test_create_reset_token( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test creating a password reset token in the database.""" + plain_token = await PasswordResetService.create_reset_token( + db_session, test_user + ) + assert plain_token is not None + + token_hash = PasswordResetService.hash_token(plain_token) + + # Verify token is saved in DB + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one_or_none() + assert db_token is not None + assert db_token.user_id == test_user.id + assert db_token.used_at is None + # Check expiration is roughly 15 minutes in future + time_diff = db_token.expires_at - datetime.now(UTC) + assert timedelta(minutes=14) < time_diff < timedelta(minutes=16) + + +@pytest.mark.anyio +async def test_create_reset_token_deletes_previous_unused( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test that creating a new token deletes any previous unused tokens for user.""" + # First token + token_1 = await PasswordResetService.create_reset_token(db_session, test_user) + hash_1 = PasswordResetService.hash_token(token_1) + + # Second token + token_2 = await PasswordResetService.create_reset_token(db_session, test_user) + hash_2 = PasswordResetService.hash_token(token_2) + + # First token should be deleted + result_1 = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_1 + ) + ) + assert result_1.scalar_one_or_none() is None + + # Second token should exist + result_2 = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_2 + ) + ) + assert result_2.scalar_one_or_none() is not None + + +@pytest.mark.anyio +async def test_create_reset_token_keeps_previous_used( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test that creating a new token does not delete previously used tokens.""" + token_1 = await PasswordResetService.create_reset_token(db_session, test_user) + hash_1 = PasswordResetService.hash_token(token_1) + + # Mark first token as used + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_1 + ) + ) + db_token_1 = result.scalar_one() + db_token_1.used_at = datetime.now(UTC) + await db_session.commit() + + # Create new token + token_2 = await PasswordResetService.create_reset_token(db_session, test_user) + hash_2 = PasswordResetService.hash_token(token_2) + + # Both tokens should exist + result_1 = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_1 + ) + ) + assert result_1.scalar_one_or_none() is not None + + result_2 = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_2 + ) + ) + assert result_2.scalar_one_or_none() is not None + + +@pytest.mark.anyio +async def test_validate_token_success( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test validating a valid token.""" + plain = await PasswordResetService.create_reset_token(db_session, test_user) + db_token = await PasswordResetService.validate_token(db_session, plain) + assert db_token is not None + assert db_token.user.id == test_user.id + + +@pytest.mark.anyio +async def test_validate_token_not_found( + db_session: AsyncSession, +) -> None: + """Test validation fails for non-existent token.""" + with pytest.raises(UnauthorizedException) as exc_info: + await PasswordResetService.validate_token(db_session, "nonexistenttoken" * 3) + assert exc_info.value.detail == "Invalid or expired password reset token" + + +@pytest.mark.anyio +async def test_validate_token_expired( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test validation fails for an expired token.""" + plain = await PasswordResetService.create_reset_token(db_session, test_user) + hash_val = PasswordResetService.hash_token(plain) + + # Make token expired in the database + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_val + ) + ) + db_token = result.scalar_one() + db_token.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db_session.commit() + + with pytest.raises(UnauthorizedException) as exc_info: + await PasswordResetService.validate_token(db_session, plain) + assert exc_info.value.detail == "Invalid or expired password reset token" + + +@pytest.mark.anyio +async def test_validate_token_already_used( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test validation fails for a token already used.""" + plain = await PasswordResetService.create_reset_token(db_session, test_user) + hash_val = PasswordResetService.hash_token(plain) + + # Make token used in the database + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == hash_val + ) + ) + db_token = result.scalar_one() + db_token.used_at = datetime.now(UTC) + await db_session.commit() + + with pytest.raises(UnauthorizedException) as exc_info: + await PasswordResetService.validate_token(db_session, plain) + assert exc_info.value.detail == "Invalid or expired password reset token" + + +@pytest.mark.anyio +async def test_reset_password_success( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test successful password reset updates user password. + + Also verifies that it revokes all active refresh sessions. + """ + # Pre-add some refresh sessions for user + session_1 = RefreshToken( + user_id=test_user.id, + token_hash="hash1", # noqa: S106 + jti="jti1", + expires_at=datetime.now(UTC) + timedelta(days=7), + ) + session_2 = RefreshToken( + user_id=test_user.id, + token_hash="hash2", # noqa: S106 + jti="jti2", + expires_at=datetime.now(UTC) + timedelta(days=7), + ) + db_session.add_all([session_1, session_2]) + await db_session.commit() + + # Create reset token + plain = await PasswordResetService.create_reset_token(db_session, test_user) + token_hash = PasswordResetService.hash_token(plain) + + # Reset password + await PasswordResetService.reset_password(db_session, plain, NEW_TEST_PASSWORD) + + # Verify password was updated + await db_session.refresh(test_user) + assert verify_password(NEW_TEST_PASSWORD, test_user.hashed_password) + + # Verify token was marked used + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one() + assert db_token.used_at is not None + + # Verify refresh sessions were revoked + sessions = await RefreshTokenService.get_active_sessions_for_user( + db_session, test_user.id + ) + assert len(sessions) == 0 + + # Ensure sessions have revoked_at timestamp set + res = await db_session.execute( + select(RefreshToken).where(RefreshToken.user_id == test_user.id) + ) + db_sessions = res.scalars().all() + assert len(db_sessions) == 2 + for s in db_sessions: + assert s.revoked_at is not None + + +@pytest.mark.anyio +async def test_cleanup_expired_tokens( + db_session: AsyncSession, + test_user: User, +) -> None: + """Test that cleanup deletes expired and already used tokens. + + Also ensures that it preserves valid unused ones. + """ + now = datetime.now(UTC) + + # 1. Expired unused token + t1 = PasswordResetToken( + user_id=test_user.id, + token_hash="hash1", # noqa: S106 + expires_at=now - timedelta(minutes=1), + used_at=None, + ) + # 2. Expired used token + t2 = PasswordResetToken( + user_id=test_user.id, + token_hash="hash2", # noqa: S106 + expires_at=now - timedelta(minutes=1), + used_at=now - timedelta(minutes=2), + ) + # 3. Non-expired used token + t3 = PasswordResetToken( + user_id=test_user.id, + token_hash="hash3", # noqa: S106 + expires_at=now + timedelta(minutes=15), + used_at=now - timedelta(seconds=10), + ) + # 4. Non-expired unused token (valid) + t4 = PasswordResetToken( + user_id=test_user.id, + token_hash="hash4", # noqa: S106 + expires_at=now + timedelta(minutes=15), + used_at=None, + ) + + db_session.add_all([t1, t2, t3, t4]) + await db_session.commit() + + deleted = await PasswordResetService.cleanup_expired_tokens(db_session) + assert deleted == 3 + + # Only t4 should remain in database + result = await db_session.execute(select(PasswordResetToken)) + remaining = result.scalars().all() + assert len(remaining) == 1 + assert remaining[0].token_hash == "hash4" # noqa: S105 + + +# ===================================================================== +# API Endpoints Integration Tests +# ===================================================================== + + +@pytest.mark.anyio +async def test_forgot_password_existing_email( + client: AsyncClient, + db_session: AsyncSession, + test_user: User, +) -> None: + """Test forgot-password endpoint with an existing email address.""" + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_email" + ) as mock_send: + response = await client.post( + "/auth/forgot-password", + json={"email": test_user.email}, + ) + assert response.status_code == 200 + assert response.json()["message"] == ( + "If an account with that email exists, a password reset link has been sent." + ) + + # Check database: reset token should have been created + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.user_id == test_user.id + ) + ) + token = result.scalar_one_or_none() + assert token is not None + assert token.used_at is None + + # Verify email send was called + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["to"] == test_user.email + assert "Reset your password" in call_kwargs["subject"] + assert "Reset Password" in call_kwargs["html"] + assert "/reset-password?token=" in call_kwargs["html"] + + +@pytest.mark.anyio +async def test_forgot_password_non_existing_email( + client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Test forgot-password endpoint with a non-existing email address.""" + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_email" + ) as mock_send: + response = await client.post( + "/auth/forgot-password", + json={"email": "nonexisting_user_email@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["message"] == ( + "If an account with that email exists, a password reset link has been sent." + ) + + # Verify no email send was called + mock_send.assert_not_called() + + +@pytest.mark.anyio +async def test_reset_password_endpoint_success( + client: AsyncClient, + db_session: AsyncSession, + test_user: User, +) -> None: + """Test successful password reset endpoint.""" + # Pre-add refresh session + session = RefreshToken( + user_id=test_user.id, + token_hash="hash_val", # noqa: S106 + jti="jti_val", + expires_at=datetime.now(UTC) + timedelta(days=7), + ) + db_session.add(session) + await db_session.commit() + + # Generate token + plain = await PasswordResetService.create_reset_token(db_session, test_user) + + response = await client.post( + "/auth/reset-password", + json={"token": plain, "new_password": NEW_TEST_PASSWORD}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Password reset successful." + + # Verify refresh sessions were revoked + sessions = await RefreshTokenService.get_active_sessions_for_user( + db_session, test_user.id + ) + assert len(sessions) == 0 + + # Login fails with old password + login_response_old = await client.post( + "/auth/login", + data={"username": test_user.username, "password": TEST_PASSWORD}, + ) + assert login_response_old.status_code == 401 + assert login_response_old.json()["detail"] == "Invalid username or password" + + # Login succeeds with new password + login_response_new = await client.post( + "/auth/login", + data={"username": test_user.username, "password": NEW_TEST_PASSWORD}, + ) + assert login_response_new.status_code == 200 + assert "access_token" in login_response_new.json() + + + +@pytest.mark.anyio +async def test_reset_password_endpoint_invalid_token( + client: AsyncClient, +) -> None: + """Test reset-password endpoint with an invalid token.""" + response = await client.post( + "/auth/reset-password", + json={"token": "invalidtoken" * 3, "new_password": NEW_TEST_PASSWORD}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or expired password reset token" + + +@pytest.mark.anyio +async def test_reset_password_endpoint_expired_token( + client: AsyncClient, + db_session: AsyncSession, + test_user: User, +) -> None: + """Test reset-password endpoint with an expired token.""" + plain = await PasswordResetService.create_reset_token(db_session, test_user) + token_hash = PasswordResetService.hash_token(plain) + + # Expire token in DB + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one() + db_token.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db_session.commit() + + response = await client.post( + "/auth/reset-password", + json={"token": plain, "new_password": NEW_TEST_PASSWORD}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or expired password reset token" + + +@pytest.mark.anyio +async def test_reset_password_endpoint_already_used_token( + client: AsyncClient, + db_session: AsyncSession, + test_user: User, +) -> None: + """Test reset-password endpoint with an already used token.""" + plain = await PasswordResetService.create_reset_token(db_session, test_user) + token_hash = PasswordResetService.hash_token(plain) + + # Use token in DB + result = await db_session.execute( + select(PasswordResetToken).where( + PasswordResetToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one() + db_token.used_at = datetime.now(UTC) + await db_session.commit() + + response = await client.post( + "/auth/reset-password", + json={"token": plain, "new_password": NEW_TEST_PASSWORD}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or expired password reset token" From 8ee83acc053ccdcc8693ae05b37631642029567e Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Mon, 6 Jul 2026 18:46:37 +0530 Subject: [PATCH 3/5] feat(ENG-25): implement email verification and password recovery --- ...fed_add_email_verification_tokens_table.py | 44 +++ .../models/__init__.py | 2 + .../models/email_verification_token.py | 62 ++++ .../models/user.py | 9 +- .../routers/auth.py | 80 ++++ .../schemas/__init__.py | 10 + .../schemas/email_verification.py | 27 ++ .../services/__init__.py | 2 + .../services/auth_service.py | 11 +- .../services/email_service.py | 112 ++++++ .../services/email_verification_service.py | 203 +++++++++++ .../services/user_service.py | 1 + tests/conftest.py | 21 ++ tests/test_email_verification.py | 344 ++++++++++++++++++ tests/test_password_reset.py | 1 + 15 files changed, 925 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/526b088ddfed_add_email_verification_tokens_table.py create mode 100644 src/ai_trading_discipline_copilot/models/email_verification_token.py create mode 100644 src/ai_trading_discipline_copilot/schemas/email_verification.py create mode 100644 src/ai_trading_discipline_copilot/services/email_verification_service.py create mode 100644 tests/test_email_verification.py diff --git a/alembic/versions/526b088ddfed_add_email_verification_tokens_table.py b/alembic/versions/526b088ddfed_add_email_verification_tokens_table.py new file mode 100644 index 0000000..e02ffcf --- /dev/null +++ b/alembic/versions/526b088ddfed_add_email_verification_tokens_table.py @@ -0,0 +1,44 @@ +"""add_email_verification_tokens_table + +Revision ID: 526b088ddfed +Revises: 3ead82c29db6 +Create Date: 2026-07-06 18:13:12.061547 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '526b088ddfed' +down_revision: Union[str, Sequence[str], None] = '3ead82c29db6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_verification_tokens', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_email_verification_tokens_user_id'), table_name='email_verification_tokens') + op.drop_table('email_verification_tokens') + # ### end Alembic commands ### diff --git a/src/ai_trading_discipline_copilot/models/__init__.py b/src/ai_trading_discipline_copilot/models/__init__.py index ea93748..2f6a2ff 100644 --- a/src/ai_trading_discipline_copilot/models/__init__.py +++ b/src/ai_trading_discipline_copilot/models/__init__.py @@ -1,5 +1,6 @@ # SQLAlchemy ORM models # Contains: base, user, trade, journal, psychology +from .email_verification_token import EmailVerificationToken from .password_reset_token import PasswordResetToken from .refresh_token import RefreshToken from .user import User @@ -8,4 +9,5 @@ "User", "RefreshToken", "PasswordResetToken", + "EmailVerificationToken", ] diff --git a/src/ai_trading_discipline_copilot/models/email_verification_token.py b/src/ai_trading_discipline_copilot/models/email_verification_token.py new file mode 100644 index 0000000..b027ec6 --- /dev/null +++ b/src/ai_trading_discipline_copilot/models/email_verification_token.py @@ -0,0 +1,62 @@ +"""Email verification token model.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ai_trading_discipline_copilot.core.database import Base + +if TYPE_CHECKING: + from .user import User + + +class EmailVerificationToken(Base): + """Stores one-time email verification tokens.""" + + __tablename__ = "email_verification_tokens" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + token_hash: Mapped[str] = mapped_column( + String(64), + nullable=False, + unique=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC) + timedelta(hours=24), + nullable=False, + ) + + used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + user: Mapped[User] = relationship( + back_populates="email_verification_tokens", + ) diff --git a/src/ai_trading_discipline_copilot/models/user.py b/src/ai_trading_discipline_copilot/models/user.py index 1329b98..a75b752 100644 --- a/src/ai_trading_discipline_copilot/models/user.py +++ b/src/ai_trading_discipline_copilot/models/user.py @@ -14,11 +14,10 @@ ) if TYPE_CHECKING: + from .email_verification_token import EmailVerificationToken from .password_reset_token import PasswordResetToken -from ai_trading_discipline_copilot.core.database import Base - -if TYPE_CHECKING: from .refresh_token import RefreshToken +from ai_trading_discipline_copilot.core.database import Base class UserRole(enum.StrEnum): @@ -66,6 +65,10 @@ class User(Base): back_populates="user", cascade="all, delete-orphan", ) + email_verification_tokens: Mapped[list[EmailVerificationToken]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) is_active: Mapped[bool] = mapped_column( Boolean, default=True, diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index 841d329..f436b60 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -26,6 +26,12 @@ ) from ai_trading_discipline_copilot.core.security import decode_refresh_token from ai_trading_discipline_copilot.models.user import User +from ai_trading_discipline_copilot.schemas.email_verification import ( + ResendVerificationRequest, + ResendVerificationResponse, + VerifyEmailRequest, + VerifyEmailResponse, +) from ai_trading_discipline_copilot.schemas.password_reset import ( ForgotPasswordRequest, ForgotPasswordResponse, @@ -40,6 +46,9 @@ ) from ai_trading_discipline_copilot.services.auth_service import AuthService from ai_trading_discipline_copilot.services.email_service import EmailService +from ai_trading_discipline_copilot.services.email_verification_service import ( + EmailVerificationService, +) from ai_trading_discipline_copilot.services.password_reset_service import ( PasswordResetService, ) @@ -95,11 +104,22 @@ async def run_cleanup_task() -> None: ) async def register( user_data: UserCreate, + background_tasks: BackgroundTasks, db: Annotated[AsyncSession, Depends(get_db)], ) -> UserResponse: """Register a new user.""" user = await UserService.register_user(db, user_data) + + plain_token = await EmailVerificationService.create_verification_token(db, user) + verification_url = f"{settings.frontend_url}/verify-email?token={plain_token}" + + background_tasks.add_task( + EmailService.send_verification_email, + to=user.email, + verification_url=verification_url, + ) + return UserResponse.model_validate(user) @@ -503,3 +523,63 @@ async def reset_password( message="Password reset successful." ) + +@router.post( + "/verify-email", + response_model=VerifyEmailResponse, + status_code=status.HTTP_200_OK, +) +async def verify_email( + request_data: VerifyEmailRequest, + db: Annotated[AsyncSession, Depends(get_db)], +) -> VerifyEmailResponse: + """Verify a user's email using a verification token.""" + try: + await EmailVerificationService.verify_email(db, request_data.token) + except UnauthorizedException as err: + raise UnauthorizedException( + "Invalid or expired email verification token" + ) from err + + return VerifyEmailResponse( + message="Email verified successfully." + ) + + +@router.post( + "/resend-verification", + response_model=ResendVerificationResponse, + status_code=status.HTTP_200_OK, +) +async def resend_verification( + request_data: ResendVerificationRequest, + background_tasks: BackgroundTasks, + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResendVerificationResponse: + """Resend email verification link. + + Always returns a generic success response to prevent account enumeration. + """ + result = await db.execute( + select(User).where(User.email == request_data.email) + ) + user = result.scalar_one_or_none() + + if user and not user.is_verified: + plain_token = await EmailVerificationService.create_verification_token( + db, user + ) + verification_url = ( + f"{settings.frontend_url}/verify-email?token={plain_token}" + ) + background_tasks.add_task( + EmailService.send_verification_email, + to=user.email, + verification_url=verification_url, + ) + + return ResendVerificationResponse( + message="Verification email sent." + ) + + diff --git a/src/ai_trading_discipline_copilot/schemas/__init__.py b/src/ai_trading_discipline_copilot/schemas/__init__.py index f55253b..f31e835 100644 --- a/src/ai_trading_discipline_copilot/schemas/__init__.py +++ b/src/ai_trading_discipline_copilot/schemas/__init__.py @@ -1,5 +1,11 @@ # Pydantic request/response schemas # Contains: user, trade, journal, psychology +from .email_verification import ( + ResendVerificationRequest, + ResendVerificationResponse, + VerifyEmailRequest, + VerifyEmailResponse, +) from .password_reset import ( ForgotPasswordRequest, ForgotPasswordResponse, @@ -15,4 +21,8 @@ "ForgotPasswordResponse", "ResetPasswordRequest", "ResetPasswordResponse", + "VerifyEmailRequest", + "VerifyEmailResponse", + "ResendVerificationRequest", + "ResendVerificationResponse", ] diff --git a/src/ai_trading_discipline_copilot/schemas/email_verification.py b/src/ai_trading_discipline_copilot/schemas/email_verification.py new file mode 100644 index 0000000..f5af8e1 --- /dev/null +++ b/src/ai_trading_discipline_copilot/schemas/email_verification.py @@ -0,0 +1,27 @@ +"""Schemas for email verification.""" + +from pydantic import BaseModel, EmailStr + + +class VerifyEmailRequest(BaseModel): + """Request to verify email.""" + + token: str + + +class VerifyEmailResponse(BaseModel): + """Response for email verification.""" + + message: str + + +class ResendVerificationRequest(BaseModel): + """Request to resend verification email.""" + + email: EmailStr + + +class ResendVerificationResponse(BaseModel): + """Response for resending verification email.""" + + message: str diff --git a/src/ai_trading_discipline_copilot/services/__init__.py b/src/ai_trading_discipline_copilot/services/__init__.py index 2389252..3d1cdda 100644 --- a/src/ai_trading_discipline_copilot/services/__init__.py +++ b/src/ai_trading_discipline_copilot/services/__init__.py @@ -1,10 +1,12 @@ # Business logic services # Contains: auth, trade, journal, psychology, ai_coach +from .email_verification_service import EmailVerificationService from .password_reset_service import PasswordResetService from .user_service import UserService __all__ = [ "UserService", "PasswordResetService", + "EmailVerificationService", ] diff --git a/src/ai_trading_discipline_copilot/services/auth_service.py b/src/ai_trading_discipline_copilot/services/auth_service.py index c3ede35..208df35 100644 --- a/src/ai_trading_discipline_copilot/services/auth_service.py +++ b/src/ai_trading_discipline_copilot/services/auth_service.py @@ -6,7 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from ai_trading_discipline_copilot.core.config import get_settings -from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException +from ai_trading_discipline_copilot.core.exceptions import ( + ForbiddenException, + UnauthorizedException, +) from ai_trading_discipline_copilot.core.security import ( create_access_token, create_refresh_token, @@ -131,6 +134,12 @@ async def login( password=password, ) + if not user.is_verified: + logger.warning( + "Blocked login attempt for unverified user: '%s'", user.username + ) + raise ForbiddenException("Please verify your email before logging in.") + access_token, refresh_token, refresh_jti = AuthService._create_tokens( user, ) diff --git a/src/ai_trading_discipline_copilot/services/email_service.py b/src/ai_trading_discipline_copilot/services/email_service.py index 306f610..7db22a7 100644 --- a/src/ai_trading_discipline_copilot/services/email_service.py +++ b/src/ai_trading_discipline_copilot/services/email_service.py @@ -31,3 +31,115 @@ async def send_email( "html": html, } ) + + @staticmethod + async def send_verification_email( + *, + to: str, + verification_url: str, + ) -> None: + """Send email verification link using Resend.""" + import html + from datetime import UTC, datetime + + escaped_app_name = html.escape(settings.app_name) + escaped_verification_url = html.escape(verification_url) + current_year = datetime.now(UTC).year + + html_content = f""" + + + + + Verify your email + + + +
+
+ {escaped_app_name} +
+
+

Hello,

+

Thank you for registering.

+

Please click the button below to verify your email:

+ +

This verification link is only valid for 24 hours.

+

If you did not request this, you can safely ignore this email.

+

+ If you're having trouble clicking the button, copy and paste the URL below:
+ {escaped_verification_url} +

+
+ +
+ +""" + + await EmailService.send_email( + to=to, + subject="Verify your email", + html=html_content, + ) diff --git a/src/ai_trading_discipline_copilot/services/email_verification_service.py b/src/ai_trading_discipline_copilot/services/email_verification_service.py new file mode 100644 index 0000000..8176e14 --- /dev/null +++ b/src/ai_trading_discipline_copilot/services/email_verification_service.py @@ -0,0 +1,203 @@ +"""Email verification service business logic.""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from sqlalchemy import delete, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException +from ai_trading_discipline_copilot.models.email_verification_token import ( + EmailVerificationToken, +) + +if TYPE_CHECKING: + from ai_trading_discipline_copilot.models.user import User + +logger = logging.getLogger(__name__) + + +class EmailVerificationService: + """Business logic for handling email verifications.""" + + @staticmethod + def generate_token() -> str: + """Generate a secure random token. + + Returns: + str: A secure random URL-safe string. + """ + return secrets.token_urlsafe(32) + + @staticmethod + def hash_token(token: str) -> str: + """Return the SHA-256 hash of a token. + + Args: + token: The plain-text token. + + Returns: + str: The hex-encoded SHA-256 hash of the token. + """ + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + @staticmethod + async def create_verification_token( + db: AsyncSession, + user: User, + ) -> str: + """Create a new email verification token. + + Generates a token, hashes it, deletes previous unused verification tokens + for the user, inserts a new EmailVerificationToken that expires in 24 hours, + commits the transaction, and returns the plain-text token. + + Args: + db: The database session. + user: The user requesting verification. + + Returns: + str: The plain-text token. + """ + plain_token = EmailVerificationService.generate_token() + token_hash = EmailVerificationService.hash_token(plain_token) + + # Delete previous unused verification tokens for the same user + await db.execute( + delete(EmailVerificationToken).where( + EmailVerificationToken.user_id == user.id, + EmailVerificationToken.used_at.is_(None), + ) + ) + + # Insert new EmailVerificationToken expiring in 24 hours + expires_at = datetime.now(UTC) + timedelta(hours=24) + verification_token = EmailVerificationToken( + user_id=user.id, + token_hash=token_hash, + expires_at=expires_at, + ) + db.add(verification_token) + await db.commit() + + logger.info( + "Created email verification token for user ID: %s expiring at %s", + user.id, + expires_at, + ) + + return plain_token + + @staticmethod + async def validate_token( + db: AsyncSession, + token: str, + ) -> EmailVerificationToken: + """Validate an email verification token. + + Hashes the incoming token, checks the database, validates that the token + exists, is not expired, and has not been used yet. + + Args: + db: The database session. + token: The plain-text verification token. + + Returns: + EmailVerificationToken: The validated token model instance with user loaded. + + Raises: + UnauthorizedException: If the token is invalid, expired, or already used. + """ + token_hash = EmailVerificationService.hash_token(token) + stmt = ( + select(EmailVerificationToken) + .where(EmailVerificationToken.token_hash == token_hash) + .options(selectinload(EmailVerificationToken.user)) + ) + result = await db.execute(stmt) + db_token = result.scalar_one_or_none() + + if db_token is None: + logger.warning("Email verification token not found in database.") + raise UnauthorizedException("Invalid or expired email verification token") + + if db_token.used_at is not None: + logger.warning( + "Email verification token already used at %s.", db_token.used_at + ) + raise UnauthorizedException("Invalid or expired email verification token") + + if db_token.expires_at <= datetime.now(UTC): + logger.warning( + "Email verification token expired at %s.", db_token.expires_at + ) + raise UnauthorizedException("Invalid or expired email verification token") + + return db_token + + @staticmethod + async def verify_email( + db: AsyncSession, + token: str, + ) -> None: + """Verify the user's email using the provided verification token. + + Validates the token, loads the user, sets is_verified=True on the user, + marks the token as used, and commits. + + Args: + db: The database session. + token: The plain-text verification token. + + Raises: + UnauthorizedException: If the token is invalid or expired. + """ + db_token = await EmailVerificationService.validate_token(db, token) + user = db_token.user + + # Mark user as verified + user.is_verified = True + + # Mark token as used + db_token.used_at = datetime.now(UTC) + + await db.commit() + logger.info( + "Successfully verified email for user ID: %s", + user.id, + ) + + @staticmethod + async def cleanup_expired_tokens( + db: AsyncSession, + ) -> int: + """Delete all expired or already-used email verification tokens. + + Args: + db: The database session. + + Returns: + int: The number of deleted tokens. + """ + from sqlalchemy.engine import CursorResult + + stmt = delete(EmailVerificationToken).where( + or_( + EmailVerificationToken.expires_at <= datetime.now(UTC), + EmailVerificationToken.used_at.is_not(None), + ) + ) + result: CursorResult[tuple[()]] = await db.execute(stmt) # type: ignore[assignment] + await db.commit() + + deleted_count: int = result.rowcount or 0 + logger.info( + "Cleaned up %d expired or used email verification tokens", deleted_count + ) + return deleted_count diff --git a/src/ai_trading_discipline_copilot/services/user_service.py b/src/ai_trading_discipline_copilot/services/user_service.py index ae33e9f..135d878 100644 --- a/src/ai_trading_discipline_copilot/services/user_service.py +++ b/src/ai_trading_discipline_copilot/services/user_service.py @@ -49,6 +49,7 @@ async def register_user( username=user_data.username, email=user_data.email, hashed_password=hash_password(user_data.password), + is_verified=False, ) db.add(user) diff --git a/tests/conftest.py b/tests/conftest.py index d4e933f..150af23 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,3 +110,24 @@ async def override_get_db() -> AsyncGenerator[AsyncSession]: yield ac app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True) +def mock_resend_emails() -> Generator[None]: + """Globally mock resend email calls during tests to prevent API key errors.""" + from unittest.mock import patch + with patch("resend.Emails.send") as mock: + yield mock + + +# Event listener to set is_verified=True on User instantiation if not explicitly passed. +# This prevents existing tests from failing since email verification wasn't present. +from sqlalchemy import event +from ai_trading_discipline_copilot.models.user import User + + +@event.listens_for(User, "init") +def receive_init(target, args, kwargs) -> None: + if "is_verified" not in kwargs: + kwargs["is_verified"] = True + diff --git a/tests/test_email_verification.py b/tests/test_email_verification.py new file mode 100644 index 0000000..c06e8dd --- /dev/null +++ b/tests/test_email_verification.py @@ -0,0 +1,344 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ai_trading_discipline_copilot.models.email_verification_token import ( + EmailVerificationToken, +) +from ai_trading_discipline_copilot.models.user import User +from ai_trading_discipline_copilot.services.email_verification_service import ( + EmailVerificationService, +) + +TEST_PASSWORD = "StrongPass1!" # noqa: S105 + + +@pytest.fixture +async def unverified_user(db_session: AsyncSession) -> User: + """Create an unverified test user in the database.""" + from ai_trading_discipline_copilot.core.security import hash_password + + user = User( + username="unverified", + email="unverified@example.com", + hashed_password=hash_password(TEST_PASSWORD), + is_verified=False, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.mark.anyio +async def test_register_sends_verification_email( + client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Test that registration automatically generates token and queues email.""" + payload = { + "username": "newreg", + "email": "newreg@example.com", + "password": TEST_PASSWORD, + } + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email" + ) as mock_send: + response = await client.post("/auth/register", json=payload) + assert response.status_code == 201 + + # Check user is created with is_verified=False + result = await db_session.execute( + select(User).where(User.username == "newreg") + ) + user = result.scalar_one_or_none() + assert user is not None + assert user.is_verified is False + + # Verify token created in DB + result_token = await db_session.execute( + select(EmailVerificationToken).where( + EmailVerificationToken.user_id == user.id + ) + ) + token = result_token.scalar_one_or_none() + assert token is not None + assert token.used_at is None + + # Verify email task was triggered + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["to"] == "newreg@example.com" + assert "/verify-email?token=" in call_kwargs["verification_url"] + + +@pytest.mark.anyio +async def test_verify_email_success( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test successful email verification endpoint.""" + plain = await EmailVerificationService.create_verification_token( + db_session, unverified_user + ) + + response = await client.post( + "/auth/verify-email", + json={"token": plain}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Email verified successfully." + + # Check database changes + await db_session.refresh(unverified_user) + assert unverified_user.is_verified is True + + token_hash = EmailVerificationService.hash_token(plain) + result = await db_session.execute( + select(EmailVerificationToken).where( + EmailVerificationToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one() + assert db_token.used_at is not None + + +@pytest.mark.anyio +async def test_verify_email_invalid_token( + client: AsyncClient, +) -> None: + """Test verify-email endpoint fails with invalid token.""" + response = await client.post( + "/auth/verify-email", + json={"token": "invalidtoken" * 3}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or expired email verification token" + + +@pytest.mark.anyio +async def test_verify_email_expired_token( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test verify-email endpoint fails with expired token.""" + plain = await EmailVerificationService.create_verification_token( + db_session, unverified_user + ) + token_hash = EmailVerificationService.hash_token(plain) + + # Expire token in DB + result = await db_session.execute( + select(EmailVerificationToken).where( + EmailVerificationToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one() + db_token.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db_session.commit() + + response = await client.post( + "/auth/verify-email", + json={"token": plain}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or expired email verification token" + + +@pytest.mark.anyio +async def test_verify_email_already_used_token( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test verify-email endpoint fails with already used token.""" + plain = await EmailVerificationService.create_verification_token( + db_session, unverified_user + ) + token_hash = EmailVerificationService.hash_token(plain) + + # Mark token used in DB + result = await db_session.execute( + select(EmailVerificationToken).where( + EmailVerificationToken.token_hash == token_hash + ) + ) + db_token = result.scalar_one() + db_token.used_at = datetime.now(UTC) + await db_session.commit() + + response = await client.post( + "/auth/verify-email", + json={"token": plain}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid or expired email verification token" + + +@pytest.mark.anyio +async def test_login_before_verification_fails( + client: AsyncClient, + unverified_user: User, +) -> None: + """Test that login attempts for unverified users return 403 Forbidden.""" + response = await client.post( + "/auth/login", + data={"username": unverified_user.username, "password": TEST_PASSWORD}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "Please verify your email before logging in." + + +@pytest.mark.anyio +async def test_login_after_verification_succeeds( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test that login succeeds once user is verified.""" + # Verify user + unverified_user.is_verified = True + await db_session.commit() + + response = await client.post( + "/auth/login", + data={"username": unverified_user.username, "password": TEST_PASSWORD}, + ) + assert response.status_code == 200 + assert "access_token" in response.json() + + +@pytest.mark.anyio +async def test_resend_verification_success( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test resending verification link to unverified user.""" + # Pre-create token to test deletion of previous token + await EmailVerificationService.create_verification_token( + db_session, unverified_user + ) + + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email" + ) as mock_send: + response = await client.post( + "/auth/resend-verification", + json={"email": unverified_user.email}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Verification email sent." + + # Verify email was sent + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["to"] == unverified_user.email + + # Verify old token deleted and only 1 remains + result = await db_session.execute( + select(EmailVerificationToken).where( + EmailVerificationToken.user_id == unverified_user.id + ) + ) + remaining = result.scalars().all() + assert len(remaining) == 1 + + +@pytest.mark.anyio +async def test_resend_verification_already_verified( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test resending to already verified user returns success but sends no email.""" + unverified_user.is_verified = True + await db_session.commit() + + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email" + ) as mock_send: + response = await client.post( + "/auth/resend-verification", + json={"email": unverified_user.email}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Verification email sent." + + # Verify no email was sent + mock_send.assert_not_called() + + +@pytest.mark.anyio +async def test_resend_verification_non_existing_email( + client: AsyncClient, +) -> None: + """Test resending to non-existent email returns success but sends no email.""" + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email" + ) as mock_send: + response = await client.post( + "/auth/resend-verification", + json={"email": "notfound@example.com"}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Verification email sent." + + # Verify no email was sent + mock_send.assert_not_called() + + +@pytest.mark.anyio +async def test_cleanup_expired_tokens( + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test cleanup function deletes used/expired verification tokens.""" + now = datetime.now(UTC) + + # 1. Expired unused token + t1 = EmailVerificationToken( + user_id=unverified_user.id, + token_hash="hash1", # noqa: S106 + expires_at=now - timedelta(minutes=1), + used_at=None, + ) + # 2. Expired used token + t2 = EmailVerificationToken( + user_id=unverified_user.id, + token_hash="hash2", # noqa: S106 + expires_at=now - timedelta(minutes=1), + used_at=now - timedelta(minutes=2), + ) + # 3. Non-expired used token + t3 = EmailVerificationToken( + user_id=unverified_user.id, + token_hash="hash3", # noqa: S106 + expires_at=now + timedelta(hours=24), + used_at=now - timedelta(seconds=10), + ) + # 4. Non-expired unused token (valid) + t4 = EmailVerificationToken( + user_id=unverified_user.id, + token_hash="hash4", # noqa: S106 + expires_at=now + timedelta(hours=24), + used_at=None, + ) + + db_session.add_all([t1, t2, t3, t4]) + await db_session.commit() + + deleted = await EmailVerificationService.cleanup_expired_tokens(db_session) + assert deleted == 3 + + # Only t4 remains + result = await db_session.execute(select(EmailVerificationToken)) + remaining = result.scalars().all() + assert len(remaining) == 1 + assert remaining[0].token_hash == "hash4" # noqa: S105 diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py index 033469c..47cff0d 100644 --- a/tests/test_password_reset.py +++ b/tests/test_password_reset.py @@ -29,6 +29,7 @@ async def test_user(db_session: AsyncSession) -> User: username="resetuser", email="resetuser@example.com", hashed_password=hash_password(TEST_PASSWORD), + is_verified=True, ) db_session.add(user) await db_session.commit() From db32fd8ada1ec1f1ab20a5e2d7bac7750b52e3ce Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Mon, 6 Jul 2026 19:24:11 +0530 Subject: [PATCH 4/5] refactor(auth): address CodeRabbit review feedback --- pyproject.toml | 2 +- .../routers/auth.py | 20 +++++-------------- .../services/email_service.py | 2 +- tests/conftest.py | 4 ++-- uv.lock | 9 +++++++-- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6a6a1c..667e8bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "httpx>=0.28.1", "pydantic-settings>=2.14.2", "pyjwt[crypto]>=2.13.0", - "resend>=2.32.2", + "resend[async]>=2.32.2", "sqlalchemy>=2.0.51", ] diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index f436b60..3093bbe 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -61,6 +61,7 @@ pass settings = get_settings() +_REFRESH_COOKIE_NAME = settings.cookie_name logger = logging.getLogger(__name__) router = APIRouter( @@ -168,7 +169,7 @@ async def refresh( ) -> Token: """Rotate the refresh token and issue a new access token.""" - refresh_token = request.cookies.get("refresh_token") + refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME) if refresh_token is None: raise UnauthorizedException("Missing refresh token") @@ -222,7 +223,7 @@ async def logout( ) -> None: """Log out the current session by revoking the refresh token and deleting the cookie.""" # noqa: E501 - refresh_token = request.cookies.get("refresh_token") + refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME) await AuthService.logout( response=response, db=db, @@ -259,7 +260,7 @@ async def get_sessions( ) -> list[UserSessionResponse]: """Get all active sessions for the authenticated user.""" - refresh_token = request.cookies.get("refresh_token") + refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME) current_jti = None if refresh_token: payload = decode_refresh_token(refresh_token) @@ -298,7 +299,7 @@ async def revoke_session( ) -> None: """Revoke a specific session for the user.""" - refresh_token = request.cookies.get("refresh_token") + refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME) session = await RefreshTokenService.get_by_id(db=db, session_id=session_id) if not session or session.user_id != current_user.id: raise NotFoundException("Session not found") @@ -330,17 +331,6 @@ async def cleanup_sessions( return {"deleted_sessions": deleted_count} -@router.post("/test-email", status_code=204) -async def test_email() -> None: - """Temporary endpoint to verify Resend integration.""" - - await EmailService.send_email( - to="sreenandpk3@gmail.com", - subject="Resend Test", - html="

Hello from AI Trading Discipline Copilot 🚀

", - ) - - async def send_reset_email_task(email: str, reset_url: str, app_name: str) -> None: """Send a password reset email asynchronously in the background. diff --git a/src/ai_trading_discipline_copilot/services/email_service.py b/src/ai_trading_discipline_copilot/services/email_service.py index 7db22a7..5987902 100644 --- a/src/ai_trading_discipline_copilot/services/email_service.py +++ b/src/ai_trading_discipline_copilot/services/email_service.py @@ -23,7 +23,7 @@ async def send_email( ) -> None: """Send an email using Resend.""" - resend.Emails.send( + await resend.Emails.send_async( { "from": settings.email_from, "to": to, diff --git a/tests/conftest.py b/tests/conftest.py index 150af23..57d8930 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,8 +116,8 @@ async def override_get_db() -> AsyncGenerator[AsyncSession]: def mock_resend_emails() -> Generator[None]: """Globally mock resend email calls during tests to prevent API key errors.""" from unittest.mock import patch - with patch("resend.Emails.send") as mock: - yield mock + with patch("resend.Emails.send") as mock_send, patch("resend.Emails.send_async") as mock_send_async: + yield mock_send # Event listener to set is_verified=True on User instantiation if not explicitly passed. diff --git a/uv.lock b/uv.lock index 3c2396c..45a1a70 100644 --- a/uv.lock +++ b/uv.lock @@ -20,7 +20,7 @@ dependencies = [ { name = "httpx" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, - { name = "resend" }, + { name = "resend", extra = ["async"] }, { name = "sqlalchemy" }, ] @@ -44,7 +44,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.13.0" }, - { name = "resend", specifier = ">=2.32.2" }, + { name = "resend", extras = ["async"], specifier = ">=2.32.2" }, { name = "sqlalchemy", specifier = ">=2.0.51" }, ] @@ -1325,6 +1325,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/e9/64b7f15e4d2b9bc363e1a6e0abc518f7aea4b8dfeab8d15a25e508c31fb2/resend-2.32.2-py2.py3-none-any.whl", hash = "sha256:1a8df5ef54b3a5cb7bb18b745f85fda07f1fa8f79b83dd9a25f6e37fbd625ef2", size = 72664, upload-time = "2026-06-17T19:47:26.915Z" }, ] +[package.optional-dependencies] +async = [ + { name = "httpx" }, +] + [[package]] name = "rich" version = "15.0.0" From b06e6a554e1dec4c7ad7cab0726f847b08615a20 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Mon, 6 Jul 2026 21:00:41 +0530 Subject: [PATCH 5/5] refactor(email): add resilient background email task wrappers --- .../routers/auth.py | 52 ++++++++++++++++--- tests/test_email_verification.py | 41 +++++++++++++++ tests/test_password_reset.py | 30 +++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index 3093bbe..a8ffcee 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -116,8 +116,9 @@ async def register( verification_url = f"{settings.frontend_url}/verify-email?token={plain_token}" background_tasks.add_task( - EmailService.send_verification_email, - to=user.email, + send_verification_email_task, + user_id=user.id, + email=user.email, verification_url=verification_url, ) @@ -331,10 +332,42 @@ async def cleanup_sessions( return {"deleted_sessions": deleted_count} -async def send_reset_email_task(email: str, reset_url: str, app_name: str) -> None: +async def send_verification_email_task( + user_id: uuid.UUID, + email: str, + verification_url: str, +) -> None: + """Send email verification link asynchronously in the background. + + Args: + user_id: The ID of the user. + email: The recipient's email address. + verification_url: The verification link. + """ + try: + await EmailService.send_verification_email( + to=email, + verification_url=verification_url, + ) + except Exception as e: + logger.exception( + "Failed to send email [type=verification] to user_id=%s, email=%s: %s", + user_id, + email, + e, + ) + + +async def send_reset_email_task( + user_id: uuid.UUID, + email: str, + reset_url: str, + app_name: str, +) -> None: """Send a password reset email asynchronously in the background. Args: + user_id: The ID of the user. email: The recipient's email address. reset_url: The plain-text password reset URL. app_name: The application name to display. @@ -443,7 +476,12 @@ async def send_reset_email_task(email: str, reset_url: str, app_name: str) -> No html=html_content, ) except Exception as e: - logger.exception("Failed to send password reset email to %s: %s", email, e) + logger.exception( + "Failed to send email [type=password_reset] to user_id=%s, email=%s: %s", + user_id, + email, + e, + ) @router.post( @@ -472,6 +510,7 @@ async def forgot_password( reset_url = f"{settings.frontend_url}/reset-password?token={plain_token}" background_tasks.add_task( send_reset_email_task, + user_id=user.id, email=user.email, reset_url=reset_url, app_name=settings.app_name, @@ -563,8 +602,9 @@ async def resend_verification( f"{settings.frontend_url}/verify-email?token={plain_token}" ) background_tasks.add_task( - EmailService.send_verification_email, - to=user.email, + send_verification_email_task, + user_id=user.id, + email=user.email, verification_url=verification_url, ) diff --git a/tests/test_email_verification.py b/tests/test_email_verification.py index c06e8dd..be0f803 100644 --- a/tests/test_email_verification.py +++ b/tests/test_email_verification.py @@ -342,3 +342,44 @@ async def test_cleanup_expired_tokens( remaining = result.scalars().all() assert len(remaining) == 1 assert remaining[0].token_hash == "hash4" # noqa: S105 + + +@pytest.mark.anyio +async def test_register_verification_email_failure_logged( + client: AsyncClient, + db_session: AsyncSession, +) -> None: + """Test registration succeeds even if background email fails. + + Verifies that the error is logged. + """ + payload = { + "username": "emailfail", + "email": "emailfail@example.com", + "password": TEST_PASSWORD, + } + + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email", + side_effect=Exception("SMTP or API connection timeout"), + ), patch( + "ai_trading_discipline_copilot.routers.auth.logger.exception" + ) as mock_log: + response = await client.post("/auth/register", json=payload) + + # Registration must still succeed immediately and return 201 + assert response.status_code == 201 + + # User should still be created + result = await db_session.execute( + select(User).where(User.username == "emailfail") + ) + user = result.scalar_one_or_none() + assert user is not None + assert user.is_verified is False + + # Verify logger.exception was called once with context + mock_log.assert_called_once() + log_args = mock_log.call_args[0] + assert "Failed to send email [type=verification]" in log_args[0] + diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py index 47cff0d..1da06cf 100644 --- a/tests/test_password_reset.py +++ b/tests/test_password_reset.py @@ -514,3 +514,33 @@ async def test_reset_password_endpoint_already_used_token( ) assert response.status_code == 401 assert response.json()["detail"] == "Invalid or expired password reset token" + + +@pytest.mark.anyio +async def test_forgot_password_email_failure_logged( + client: AsyncClient, + db_session: AsyncSession, + test_user: User, +) -> None: + """Test requesting reset succeeds even if background email fails. + + Verifies that the error is logged. + """ + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_email", + side_effect=Exception("Resend API key invalid"), + ), patch( + "ai_trading_discipline_copilot.routers.auth.logger.exception" + ) as mock_log: + response = await client.post( + "/auth/forgot-password", + json={"email": test_user.email}, + ) + assert response.status_code == 200 + assert "password reset link has been sent" in response.json()["message"] + + # Logged failure with password_reset context + mock_log.assert_called_once() + log_args = mock_log.call_args[0] + assert "Failed to send email [type=password_reset]" in log_args[0] +