Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ AI_MODEL=gpt-4o # Model to use for coaching responses

# ── CORS ───────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed frontend origins
ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"]
ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:5173","http://localhost:8000"]

# ── Authentication & Security ──────────────────────────────────────────────────
BCRYPT_ROUNDS=12
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- AI_MODEL=gpt-4o

- ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"]
- ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:5173","http://localhost:8000"]

- RESEND_API_KEY=${RESEND_API_KEY}
- EMAIL_FROM=${EMAIL_FROM}
Expand Down
1 change: 1 addition & 0 deletions src/ai_trading_discipline_copilot/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class Settings(BaseSettings):
# CORS
allowed_origins: list[str] = [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:8000",
]

Expand Down
65 changes: 59 additions & 6 deletions src/ai_trading_discipline_copilot/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from ai_trading_discipline_copilot.core.config import get_settings
Expand All @@ -30,7 +30,7 @@
UnauthorizedException,
)
from ai_trading_discipline_copilot.core.limiter import limiter
from ai_trading_discipline_copilot.core.security import decode_refresh_token
from ai_trading_discipline_copilot.core.security import decode_refresh_token, hash_refresh_token
from ai_trading_discipline_copilot.models.user import User
from ai_trading_discipline_copilot.schemas.email_verification import (
ResendVerificationRequest,
Expand Down Expand Up @@ -576,18 +576,64 @@ async def reset_password(
status_code=status.HTTP_200_OK,
)
async def verify_email(
request: Request,
response: Response,
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)
user = 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.")
# Automatically log the user in
try:
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
device_name = parse_device_name(user_agent)

access_token, refresh_token, refresh_jti = AuthService._create_tokens(user)

await RefreshTokenService.create_session(
db=db,
user=user,
token_hash=hash_refresh_token(refresh_token),
jti=refresh_jti,
ip_address=ip_address,
user_agent=user_agent,
device_name=device_name,
)

AuthService.set_refresh_cookie(
response=response,
refresh_token=refresh_token,
)

logger.info(
"User '%s' verified and automatically logged in. IP: %s, Device: %s",
user.username,
ip_address,
device_name,
)

return VerifyEmailResponse(
message="Email verified successfully.",
access_token=access_token,
token_type="bearer",
)
except Exception:
logger.exception(
"Email verified successfully for user '%s', but automatic login failed.",
user.username,
)
return VerifyEmailResponse(
message="Email verified successfully.",
access_token=None,
token_type=None,
)


@router.post(
Expand All @@ -606,7 +652,14 @@ async def resend_verification(

Always returns a generic success response to prevent account enumeration.
"""
result = await db.execute(select(User).where(User.email == request_data.email))
result = await db.execute(
select(User).where(
or_(
User.email == request_data.username_or_email,
User.username == request_data.username_or_email,
)
)
)
user = result.scalar_one_or_none()

if user and not user.is_verified:
Expand Down Expand Up @@ -728,7 +781,7 @@ async def google_callback(
raise UnauthorizedException("Google email account is not verified")

# 4. Perform login or registration, attaching cookies directly to RedirectResponse
redirect_url = f"{settings.frontend_url}/auth/callback"
redirect_url = f"{settings.frontend_url}/#/auth/callback"
redirect_response = RedirectResponse(url=redirect_url)

ip_address = request.client.host if request.client else None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ class VerifyEmailResponse(BaseModel):
"""Response for email verification."""

message: str
access_token: str | None = None
token_type: str | None = None


class ResendVerificationRequest(BaseModel):
"""Request to resend verification email."""

email: EmailStr
username_or_email: str


class ResendVerificationResponse(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ async def validate_token(
async def verify_email(
db: AsyncSession,
token: str,
) -> None:
) -> User:
"""Verify the user's email using the provided verification token.

Validates the token, loads the user, sets is_verified=True on the user,
Expand All @@ -155,6 +155,9 @@ async def verify_email(
db: The database session.
token: The plain-text verification token.

Returns:
The verified User object.

Raises:
UnauthorizedException: If the token is invalid or expired.
"""
Expand All @@ -172,6 +175,7 @@ async def verify_email(
"Successfully verified email for user ID: %s",
user.id,
)
return user

@staticmethod
async def cleanup_expired_tokens(
Expand Down
52 changes: 49 additions & 3 deletions tests/test_email_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ async def test_verify_email_success(
)
assert response.status_code == 200
assert response.json()["message"] == "Email verified successfully."
assert "access_token" in response.json()
assert response.json()["token_type"] == "bearer"
assert "refresh_token" in response.cookies

# Check database changes
await db_session.refresh(unverified_user)
Expand Down Expand Up @@ -231,7 +234,7 @@ async def test_resend_verification_success(
) as mock_send:
response = await client.post(
"/auth/resend-verification",
json={"email": unverified_user.email},
json={"username_or_email": unverified_user.email},
)
assert response.status_code == 200
assert response.json()["message"] == "Verification email sent."
Expand Down Expand Up @@ -266,7 +269,7 @@ async def test_resend_verification_already_verified(
) as mock_send:
response = await client.post(
"/auth/resend-verification",
json={"email": unverified_user.email},
json={"username_or_email": unverified_user.email},
)
assert response.status_code == 200
assert response.json()["message"] == "Verification email sent."
Expand All @@ -285,7 +288,7 @@ async def test_resend_verification_non_existing_email(
) as mock_send:
response = await client.post(
"/auth/resend-verification",
json={"email": "notfound@example.com"},
json={"username_or_email": "notfound@example.com"},
)
assert response.status_code == 200
assert response.json()["message"] == "Verification email sent."
Expand All @@ -294,6 +297,25 @@ async def test_resend_verification_non_existing_email(
mock_send.assert_not_called()


@pytest.mark.anyio
async def test_resend_verification_by_username(
client: AsyncClient,
db_session: AsyncSession,
unverified_user: User,
) -> None:
"""Test resending verification link using username instead of 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={"username_or_email": unverified_user.username},
)
assert response.status_code == 200
assert response.json()["message"] == "Verification email sent."
mock_send.assert_called_once()


@pytest.mark.anyio
async def test_cleanup_expired_tokens(
db_session: AsyncSession,
Expand Down Expand Up @@ -383,3 +405,27 @@ async def test_register_verification_email_failure_logged(
log_args = mock_log.call_args[0]
assert "Failed to send email [type=verification]" in log_args[0]


@pytest.mark.anyio
async def test_email_verification_service_direct(
db_session: AsyncSession,
) -> None:
"""Test EmailVerificationService.verify_email directly in Python to verify logic and hit coverage."""
from ai_trading_discipline_copilot.core.security import hash_password
user = User(
username="directuser",
email="direct@example.com",
hashed_password=hash_password("Pass123!"),
is_verified=False,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)

plain = await EmailVerificationService.create_verification_token(
db_session, user
)
verified_user = await EmailVerificationService.verify_email(db_session, plain)
assert verified_user.id == user.id
assert verified_user.is_verified is True