From 0647701c1c1a108d72b353beff0578328d14a2df Mon Sep 17 00:00:00 2001 From: aniebietafia Date: Wed, 18 Mar 2026 00:11:55 +0100 Subject: [PATCH 1/2] feat(auth): add email verification and resend verification flow add VerificationToken model and migration for persistent token storage implement class-based verification logic via VerificationTokenRepository and AuthVerificationService add GET /api/v1/auth/verify-email with token validation, expiry handling, and idempotent verified-user behavior add POST /api/v1/auth/resend-verification with enumeration-safe response and 3/minute rate limit integrate signup to generate verification tokens and enqueue email dispatch via Kafka email producer add standardized 429 error handling through custom rate-limit exception handler document endpoints in docs/auth_verification_api.md add tests for verification CRUD and API flows (success, missing token, invalid token, expired token, resend cases) keep code quality gates compliant (ruff, isort, mypy) and auth tests green Signed-off-by: aniebietafia --- .env.example | 1 + ...b6b5d1c2a_add_verification_tokens_table.py | 51 +++++ app/api/v1/endpoints/auth.py | 120 +++++++++- app/core/config.py | 1 + app/core/rate_limiter.py | 20 ++ app/crud/verification_token.py | 57 +++++ app/main.py | 8 + app/models/__init__.py | 3 +- app/models/verification_token.py | 31 +++ app/schemas/auth.py | 9 + app/services/auth_verification.py | 115 ++++++++++ docs/auth_verification_api.md | 83 +++++++ issue.md | 73 +++++++ tests/test_auth/test_email_verification.py | 206 ++++++++++++++++++ .../test_auth/test_verification_token_crud.py | 117 ++++++++++ 15 files changed, 892 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py create mode 100644 app/core/rate_limiter.py create mode 100644 app/crud/verification_token.py create mode 100644 app/models/verification_token.py create mode 100644 app/services/auth_verification.py create mode 100644 docs/auth_verification_api.md create mode 100644 issue.md create mode 100644 tests/test_auth/test_email_verification.py create mode 100644 tests/test_auth/test_verification_token_crud.py diff --git a/.env.example b/.env.example index b3d2f37..0a4c747 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ API_V1_STR="/api/v1" SECRET_KEY="your-super-secret-key-here" ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=7 +VERIFICATION_TOKEN_EXPIRE_HOURS=24 # Database (PostgreSQL) POSTGRES_SERVER=localhost diff --git a/alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py b/alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py new file mode 100644 index 0000000..6acb583 --- /dev/null +++ b/alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py @@ -0,0 +1,51 @@ +"""add verification tokens table + +Revision ID: 4b4b6b5d1c2a +Revises: 11781e907181 +Create Date: 2026-03-17 11:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4b4b6b5d1c2a" +down_revision: str | Sequence[str] | None = "11781e907181" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "verification_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_verification_tokens_id"), + "verification_tokens", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_verification_tokens_token"), + "verification_tokens", + ["token"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_verification_tokens_token"), table_name="verification_tokens" + ) + op.drop_index(op.f("ix_verification_tokens_id"), table_name="verification_tokens") + op.drop_table("verification_tokens") diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py index 5b923d9..8897263 100644 --- a/app/api/v1/endpoints/auth.py +++ b/app/api/v1/endpoints/auth.py @@ -1,18 +1,25 @@ import logging from uuid import uuid4 -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, Query, Request, status from sqlalchemy.orm import Session from app.core.config import settings +from app.core.rate_limiter import limiter from app.crud.user.user import create_user, get_user_by_email from app.db.session import get_db from app.schemas.auth import ( ActionAcknowledgement, ForgotPasswordRequest, + ResendVerificationRequest, SignupResponse, + VerifyEmailResponse, ) from app.schemas.user import UserCreate +from app.services.auth_verification import ( + AuthVerificationService, + get_auth_verification_service, +) from app.services.email_producer import EmailProducerService, get_email_producer_service logger = logging.getLogger(__name__) @@ -20,6 +27,7 @@ router = APIRouter(prefix="/auth", tags=["auth"]) DB_SESSION_DEPENDENCY = Depends(get_db) EMAIL_PRODUCER_DEPENDENCY = Depends(get_email_producer_service) +AUTH_VERIFICATION_SERVICE_DEPENDENCY = Depends(get_auth_verification_service) @router.post( @@ -31,11 +39,18 @@ async def signup( user_in: UserCreate, db: Session = DB_SESSION_DEPENDENCY, email_producer: EmailProducerService = EMAIL_PRODUCER_DEPENDENCY, + auth_verification_service: AuthVerificationService = ( + AUTH_VERIFICATION_SERVICE_DEPENDENCY + ), ) -> SignupResponse: user = create_user(db=db, user_in=user_in) + verification_token = auth_verification_service.create_verification_token( + db=db, + user_id=user.id, + ) verification_link = ( - f"{settings.FRONTEND_BASE_URL}/verify-email?user={user.id}&token={uuid4()}" + f"{settings.FRONTEND_BASE_URL}/verify-email?token={verification_token.token}" ) try: await email_producer.send_email( @@ -91,3 +106,104 @@ async def forgot_password( "password reset instructions." ) ) + + +@router.get( + "/verify-email", + response_model=VerifyEmailResponse, + status_code=status.HTTP_200_OK, + summary="Verify user email address", + description=( + "Validates an email verification token, activates the user account, " + "and invalidates the token." + ), + responses={ + 400: { + "description": "Missing, invalid, or expired token", + "content": { + "application/json": { + "examples": { + "missing": { + "value": { + "status": "error", + "code": "MISSING_TOKEN", + "message": "Verification token is required.", + "details": [], + } + }, + "invalid": { + "value": { + "status": "error", + "code": "INVALID_TOKEN", + "message": "Verification token is invalid.", + "details": [], + } + }, + "expired": { + "value": { + "status": "error", + "code": "TOKEN_EXPIRED", + "message": ( + "Verification token has expired. " + "Please request a new one." + ), + "details": [], + } + }, + } + } + }, + } + }, +) +def verify_email( + token: str | None = Query(default=None), + db: Session = DB_SESSION_DEPENDENCY, + auth_verification_service: AuthVerificationService = ( + AUTH_VERIFICATION_SERVICE_DEPENDENCY + ), +) -> VerifyEmailResponse: + auth_verification_service.verify_email(db=db, token=token) + return VerifyEmailResponse( + message="Email successfully verified. You can now log in.", + ) + + +@router.post( + "/resend-verification", + response_model=ActionAcknowledgement, + status_code=status.HTTP_200_OK, + summary="Resend email verification link", + description=( + "Queues a new verification email when the account exists and is not " + "verified. Always returns a generic response to prevent user enumeration." + ), +) +@limiter.limit("3/minute") +async def resend_verification( + request: Request, + payload: ResendVerificationRequest, + db: Session = DB_SESSION_DEPENDENCY, + email_producer: EmailProducerService = EMAIL_PRODUCER_DEPENDENCY, + auth_verification_service: AuthVerificationService = ( + AUTH_VERIFICATION_SERVICE_DEPENDENCY + ), +) -> ActionAcknowledgement: + del request + try: + await auth_verification_service.resend_verification_email( + db=db, + email=str(payload.email), + email_producer=email_producer, + ) + except Exception as exc: + logger.warning( + "Failed to enqueue verification resend for %s: %s", payload.email, exc + ) + + return ActionAcknowledgement( + message=( + "If an account with that email exists, we have sent a verification " + "email." + ) + ) diff --git a/app/core/config.py b/app/core/config.py index afc7832..ee18617 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): SECRET_KEY: str = "placeholder_secret_key" ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + VERIFICATION_TOKEN_EXPIRE_HOURS: int = 24 # Database POSTGRES_SERVER: str = "localhost" diff --git a/app/core/rate_limiter.py b/app/core/rate_limiter.py new file mode 100644 index 0000000..5c428a9 --- /dev/null +++ b/app/core/rate_limiter.py @@ -0,0 +1,20 @@ +from fastapi import Request +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from app.core.error_responses import create_error_response + +limiter = Limiter(key_func=get_remote_address) + + +async def rate_limit_exception_handler( + _request: Request, + _exc: RateLimitExceeded, +) -> JSONResponse: + return create_error_response( + status_code=429, + code="RATE_LIMIT_EXCEEDED", + message="Too many requests. Please try again later.", + ) diff --git a/app/crud/verification_token.py b/app/crud/verification_token.py new file mode 100644 index 0000000..7f2d288 --- /dev/null +++ b/app/crud/verification_token.py @@ -0,0 +1,57 @@ +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.verification_token import VerificationToken + + +class VerificationTokenRepository: + def get_token(self, db: Session, token: str) -> VerificationToken | None: + statement = select(VerificationToken).where(VerificationToken.token == token) + return db.execute(statement).scalar_one_or_none() + + def create_token(self, db: Session, user_id: int) -> VerificationToken: + expires_at = datetime.now(UTC) + timedelta( + hours=settings.VERIFICATION_TOKEN_EXPIRE_HOURS + ) + verification_token = VerificationToken(user_id=user_id, expires_at=expires_at) + db.add(verification_token) + db.commit() + db.refresh(verification_token) + return verification_token + + def delete_token(self, db: Session, token_id: int) -> None: + token = db.get(VerificationToken, token_id) + if token is None: + return + db.delete(token) + db.commit() + + def delete_unexpired_tokens_for_user(self, db: Session, user_id: int) -> None: + now = datetime.now(UTC) + statement = select(VerificationToken).where( + VerificationToken.user_id == user_id, + VerificationToken.expires_at >= now, + ) + tokens = db.execute(statement).scalars().all() + for token in tokens: + db.delete(token) + if tokens: + db.commit() + + +verification_token_repository = VerificationTokenRepository() + + +def get_token(db: Session, token: str) -> VerificationToken | None: + return verification_token_repository.get_token(db=db, token=token) + + +def create_token(db: Session, user_id: int) -> VerificationToken: + return verification_token_repository.create_token(db=db, user_id=user_id) + + +def delete_token(db: Session, token_id: int) -> None: + verification_token_repository.delete_token(db=db, token_id=token_id) diff --git a/app/main.py b/app/main.py index 8bdafed..8f80baa 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,16 @@ import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from typing import Any, cast from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi.errors import RateLimitExceeded from app.api.v1 import api_router from app.core.config import settings from app.core.exception_handlers import register_exception_handlers +from app.core.rate_limiter import limiter, rate_limit_exception_handler from app.kafka.manager import get_kafka_manager logger = logging.getLogger(__name__) @@ -47,6 +50,11 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: ) register_exception_handlers(app) +app.state.limiter = limiter +app.add_exception_handler( + RateLimitExceeded, + cast(Any, rate_limit_exception_handler), +) app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/app/models/__init__.py b/app/models/__init__.py index 29cde63..d290758 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.user import Base, User +from app.models.verification_token import VerificationToken # Export all models for Alembic -__all__ = ["Base", "User"] +__all__ = ["Base", "User", "VerificationToken"] diff --git a/app/models/verification_token.py b/app/models/verification_token.py new file mode 100644 index 0000000..47107dd --- /dev/null +++ b/app/models/verification_token.py @@ -0,0 +1,31 @@ +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.user import Base + + +def default_expiry() -> datetime: + return datetime.now(UTC) + timedelta(hours=24) + + +class VerificationToken(Base): + __tablename__ = "verification_tokens" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + token: Mapped[str] = mapped_column( + String(36), + unique=True, + index=True, + nullable=False, + default=lambda: str(uuid.uuid4()), + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=default_expiry + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 9ae4637..c27391c 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -13,3 +13,12 @@ class ForgotPasswordRequest(BaseModel): class ActionAcknowledgement(BaseModel): message: str + + +class VerifyEmailResponse(BaseModel): + status: str = "ok" + message: str + + +class ResendVerificationRequest(BaseModel): + email: EmailStr diff --git a/app/services/auth_verification.py b/app/services/auth_verification.py new file mode 100644 index 0000000..1094fa0 --- /dev/null +++ b/app/services/auth_verification.py @@ -0,0 +1,115 @@ +from datetime import UTC, datetime +from typing import Final +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.exceptions import BadRequestException +from app.crud.user.user import get_user_by_email +from app.crud.verification_token import ( + VerificationTokenRepository, + verification_token_repository, +) +from app.models.user import User +from app.models.verification_token import VerificationToken +from app.services.email_producer import EmailProducerService + + +def _to_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +class AuthVerificationService: + def __init__( + self, + token_repository: VerificationTokenRepository = verification_token_repository, + ) -> None: + self._token_repository: Final[VerificationTokenRepository] = token_repository + + def create_verification_token(self, db: Session, user_id: int) -> VerificationToken: + return self._token_repository.create_token(db=db, user_id=user_id) + + def verify_email(self, db: Session, token: str | None) -> None: + if token is None: + raise BadRequestException( + code="MISSING_TOKEN", + message="Verification token is required.", + ) + + parsed_token = self._validate_token_format(token) + verification_token = self._token_repository.get_token( + db=db, + token=parsed_token, + ) + if verification_token is None: + raise BadRequestException( + code="INVALID_TOKEN", + message="Verification token is invalid.", + ) + + token_expiry = _to_aware_utc(verification_token.expires_at) + if token_expiry < datetime.now(UTC): + raise BadRequestException( + code="TOKEN_EXPIRED", + message="Verification token has expired. Please request a new one.", + ) + + user = db.get(User, verification_token.user_id) + if user is None: + raise BadRequestException( + code="INVALID_TOKEN", + message="Verification token is invalid.", + ) + + try: + if not user.is_verified: + user.is_verified = True + user.updated_at = datetime.now(UTC) + db.delete(verification_token) + db.commit() + except Exception: + db.rollback() + raise + + async def resend_verification_email( + self, + db: Session, + email: str, + email_producer: EmailProducerService, + ) -> None: + user = get_user_by_email(db, email) + if user is None or user.is_verified: + return + + self._token_repository.delete_unexpired_tokens_for_user(db=db, user_id=user.id) + token = self._token_repository.create_token(db=db, user_id=user.id) + + verification_link = ( + f"{settings.FRONTEND_BASE_URL}/verify-email?token={token.token}" + ) + await email_producer.send_email( + to=user.email, + subject="Verify your FluentMeet account", + html_body=None, + template_data={"verification_link": verification_link}, + template="verification", + ) + + def _validate_token_format(self, token: str) -> str: + try: + return str(UUID(token)) + except ValueError as exc: + raise BadRequestException( + code="INVALID_TOKEN", + message="Verification token is invalid.", + ) from exc + + +auth_verification_service = AuthVerificationService() + + +def get_auth_verification_service() -> AuthVerificationService: + return auth_verification_service diff --git a/docs/auth_verification_api.md b/docs/auth_verification_api.md new file mode 100644 index 0000000..4d9f6c0 --- /dev/null +++ b/docs/auth_verification_api.md @@ -0,0 +1,83 @@ +# Email Verification API + +This document describes the email verification endpoints in `app/api/v1/endpoints/auth.py`. + +## 1) Verify email + +- **Method**: `GET` +- **Path**: `/api/v1/auth/verify-email` +- **Auth required**: No +- **Query params**: + - `token` (string UUID) + +### Success response + +```json +{ + "status": "ok", + "message": "Email successfully verified. You can now log in." +} +``` + +### Error responses + +- Missing token (`400`) + +```json +{ + "status": "error", + "code": "MISSING_TOKEN", + "message": "Verification token is required.", + "details": [] +} +``` + +- Invalid token (`400`) + +```json +{ + "status": "error", + "code": "INVALID_TOKEN", + "message": "Verification token is invalid.", + "details": [] +} +``` + +- Expired token (`400`) + +```json +{ + "status": "error", + "code": "TOKEN_EXPIRED", + "message": "Verification token has expired. Please request a new one.", + "details": [] +} +``` + +## 2) Resend verification + +- **Method**: `POST` +- **Path**: `/api/v1/auth/resend-verification` +- **Auth required**: No +- **Rate limit**: `3/minute` per IP +- **Request body**: + +```json +{ + "email": "user@example.com" +} +``` + +### Response (`200` for both existing and non-existing emails) + +```json +{ + "message": "If an account with that email exists, we have sent a verification email." +} +``` + +## Notes + +- Signup creates a verification token and queues the verification email through Kafka topic `notifications.email`. +- Verification tokens are single-use: successful verification deletes the token. +- Already verified users are handled idempotently by `GET /verify-email` and receive `200`. diff --git a/issue.md b/issue.md new file mode 100644 index 0000000..bd27abb --- /dev/null +++ b/issue.md @@ -0,0 +1,73 @@ +### Feature: Implement GET /api/v1/auth/verify-email — Email Verification Endpoint + +**Problem** +After signup, new user accounts are created with `is_verified=False`. Without an email verification endpoint, there is no way for users to activate their accounts, and the `is_verified` flag serves no purpose. Unverified users are blocked from logging in (enforced in the `/login` endpoint), creating a dead end if the verification link cannot be processed. + +**Proposed Solution** +Implement `GET /api/v1/auth/verify-email?token=` which looks up the verification token in the database, checks it is valid and unexpired, marks the user as verified (`is_verified=True`), and invalidates the token to prevent reuse. Since this endpoint is accessed by clicking a link in an email, it must be a `GET` request with the token as a query parameter. + +**User Stories** +* **As a new user,** I want to click the link in my verification email and have my account immediately activated, so I can log in without any further steps. +* **As a new user,** I want to see a clear error if my verification link has expired, with guidance on how to request a new one, so I am not left confused with an inactive account. +* **As a security engineer,** I want each verification token to be single-use and time-limited, so that a leaked or intercepted verification link cannot be used to verify an account it was not sent to. + +**Acceptance Criteria** +1. `GET /api/v1/auth/verify-email?token=` is a public endpoint (no authentication required). +2. **Token Lookup & Validation**: + * If the `token` query parameter is absent, return `400 Bad Request`: + ```json + { "status": "error", "code": "MISSING_TOKEN", "message": "Verification token is required.", "details": [] } + ``` + * If no matching `VerificationToken` record is found, return `400 Bad Request`: + ```json + { "status": "error", "code": "INVALID_TOKEN", "message": "Verification token is invalid.", "details": [] } + ``` + * If the token exists but `expires_at < now()`, return `400 Bad Request`: + ```json + { "status": "error", "code": "TOKEN_EXPIRED", "message": "Verification token has expired. Please request a new one.", "details": [] } + ``` + * If the token's associated user is already verified (`is_verified=True`), return `200 OK` idempotently — do not treat this as an error. +3. **On Valid Token**: + * Set `user.is_verified = True` and `user.updated_at = now()` in the database. + * Delete the `VerificationToken` record to prevent reuse. + * Both operations are performed in a single atomic database transaction. +4. On success, return `200 OK`: + ```json + { "status": "ok", "message": "Email successfully verified. You can now log in." } + ``` +5. A **resend verification** endpoint (`POST /api/v1/auth/resend-verification`) is implemented alongside this one, allowing users with expired tokens to request a new verification email. It: + * Accepts `{ "email": "user@example.com" }`. + * Deletes any existing unexpired token for the user before generating a new one. + * Is rate-limited to **3 requests/minute** per IP to prevent email flooding. + * Always returns `200 OK` regardless of whether the email exists, to prevent user enumeration. +6. Unit and integration tests cover: valid token, invalid token, expired token, already-verified user (idempotent), and resend flow. + +**Proposed Technical Details** +* **Router**: `app/api/v1/endpoints/auth.py` — new `GET /verify-email` and `POST /resend-verification` routes. +* **Token Model**: `VerificationToken` (created in the [auth_signup issue](./auth_signup.md)) — fields: `id`, `user_id` (FK), `token` (UUID, unique, indexed), `expires_at` (default: `now() + 24h`), `created_at`. +* **CRUD** in `app/crud/verification_token.py`: + * `get_token(db, token: str) -> VerificationToken | None` + * `delete_token(db, token_id: int) -> None` + * `create_token(db, user_id: int) -> VerificationToken` +* **Email Trigger for Resend**: Publishes to `notifications.email` Kafka topic (same as signup) with `template: "verification"`. +* **Atomic Transaction**: `user.is_verified = True` and `delete_token` are wrapped in a single `db.begin()` / `db.commit()` block to ensure consistency if either fails. +* **New/Modified Files**: + * `app/api/v1/endpoints/auth.py` — add `GET /verify-email`, `POST /resend-verification` [MODIFY] + * `app/crud/verification_token.py` — token CRUD operations [NEW] + +**Tasks** +- [ ] Implement `get_token`, `delete_token`, and `create_token` in `app/crud/verification_token.py`. +- [ ] Implement `GET /api/v1/auth/verify-email` in `app/api/v1/endpoints/auth.py`. +- [ ] Ensure `user.is_verified = True` and token deletion are wrapped in a single atomic transaction. +- [ ] Handle already-verified user idempotently (return `200` without error). +- [ ] Implement `POST /api/v1/auth/resend-verification` with email enumeration protection. +- [ ] Apply `@limiter.limit("3/minute")` to the resend endpoint. +- [ ] Integrate `EmailProducerService` in the resend flow to publish to `notifications.email`. +- [ ] Write unit tests for all `verification_token` CRUD functions. +- [ ] Write integration tests: valid token, invalid token, expired token, already verified (idempotent), and resend flow. + +**Open Questions/Considerations** +* Should a successfully verified user be automatically logged in and receive tokens in the `/verify-email` response, or should they be redirected to the login page to authenticate separately? +* Should the verification link redirect to a frontend URL (e.g., `https://app.fluentmeet.com/verified`) rather than returning JSON, since this endpoint is opened in a browser? +* Should the verification token expiry be configurable via settings (`VERIFICATION_TOKEN_EXPIRE_HOURS`), or fixed at 24 hours? +* If the user never verifies their email, should we schedule an automatic cleanup job to purge unverified accounts older than a threshold (e.g., 7 days)? diff --git a/tests/test_auth/test_email_verification.py b/tests/test_auth/test_email_verification.py new file mode 100644 index 0000000..ec3adcf --- /dev/null +++ b/tests/test_auth/test_email_verification.py @@ -0,0 +1,206 @@ +from collections.abc import Generator +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.session import get_db +from app.main import app +from app.models.user import Base, User +from app.models.verification_token import VerificationToken +from app.services.email_producer import get_email_producer_service + + +@pytest.fixture +def db_session() -> Generator[Session, None, None]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + testing_session_local = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + ) + Base.metadata.create_all(bind=engine) + db = testing_session_local() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture +def email_producer_mock() -> AsyncMock: + mock = AsyncMock() + mock.send_email = AsyncMock() + return mock + + +@pytest.fixture +def client( + db_session: Session, email_producer_mock: AsyncMock +) -> Generator[TestClient, None, None]: + def _override_get_db() -> Generator[Session, None, None]: + yield db_session + + def _override_email_producer() -> AsyncMock: + return email_producer_mock + + app.dependency_overrides[get_db] = _override_get_db + app.dependency_overrides[get_email_producer_service] = _override_email_producer + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +def _create_unverified_user(client: TestClient, email: str) -> None: + payload = { + "email": email, + "password": "MyStr0ngP@ss!", + "full_name": "Email Verify", + } + response = client.post("/api/v1/auth/signup", json=payload) + assert response.status_code == 201 + + +def _get_verification_token(db_session: Session, email: str) -> VerificationToken: + user = db_session.execute(select(User).where(User.email == email)).scalar_one() + statement = select(VerificationToken).where(VerificationToken.user_id == user.id) + return db_session.execute(statement).scalar_one() + + +def test_verify_email_success_marks_user_verified_and_deletes_token( + client: TestClient, + db_session: Session, +) -> None: + email = "verify-success@example.com" + _create_unverified_user(client, email) + token = _get_verification_token(db_session, email) + + response = client.get(f"/api/v1/auth/verify-email?token={token.token}") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "message": "Email successfully verified. You can now log in.", + } + + user = db_session.execute(select(User).where(User.email == email)).scalar_one() + assert user.is_verified is True + assert db_session.get(VerificationToken, token.id) is None + + +def test_verify_email_missing_token_returns_custom_error(client: TestClient) -> None: + response = client.get("/api/v1/auth/verify-email") + + assert response.status_code == 400 + assert response.json() == { + "status": "error", + "code": "MISSING_TOKEN", + "message": "Verification token is required.", + "details": [], + } + + +def test_verify_email_invalid_token_returns_custom_error(client: TestClient) -> None: + response = client.get( + "/api/v1/auth/verify-email?token=8f14e45f-ceea-4f6a-9fef-3d4d3e0d1be1" + ) + + assert response.status_code == 400 + assert response.json() == { + "status": "error", + "code": "INVALID_TOKEN", + "message": "Verification token is invalid.", + "details": [], + } + + +def test_verify_email_expired_token_returns_token_expired( + client: TestClient, + db_session: Session, +) -> None: + email = "verify-expired@example.com" + _create_unverified_user(client, email) + token = _get_verification_token(db_session, email) + token.expires_at = datetime.now(UTC) - timedelta(minutes=1) + db_session.commit() + + response = client.get(f"/api/v1/auth/verify-email?token={token.token}") + + assert response.status_code == 400 + assert response.json() == { + "status": "error", + "code": "TOKEN_EXPIRED", + "message": "Verification token has expired. Please request a new one.", + "details": [], + } + + +def test_verify_email_already_verified_is_idempotent( + client: TestClient, + db_session: Session, +) -> None: + email = "verify-idempotent@example.com" + _create_unverified_user(client, email) + token = _get_verification_token(db_session, email) + + user = db_session.execute(select(User).where(User.email == email)).scalar_one() + user.is_verified = True + db_session.commit() + + response = client.get(f"/api/v1/auth/verify-email?token={token.token}") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + assert db_session.get(VerificationToken, token.id) is None + + +def test_resend_verification_generates_new_token_and_enqueues_email( + client: TestClient, + db_session: Session, + email_producer_mock: AsyncMock, +) -> None: + email = "resend@example.com" + _create_unverified_user(client, email) + old_token = _get_verification_token(db_session, email) + old_value = old_token.token + email_producer_mock.send_email.reset_mock() + + response = client.post( + "/api/v1/auth/resend-verification", + json={"email": email}, + ) + + assert response.status_code == 200 + assert response.json() == { + "message": ( + "If an account with that email exists, we have sent a verification email." + ) + } + email_producer_mock.send_email.assert_awaited_once() + + new_token = _get_verification_token(db_session, email) + assert new_token.token != old_value + + +def test_resend_verification_for_missing_user_is_enumeration_safe( + client: TestClient, + email_producer_mock: AsyncMock, +) -> None: + response = client.post( + "/api/v1/auth/resend-verification", + json={"email": "not-found@example.com"}, + ) + + assert response.status_code == 200 + assert response.json()["message"].startswith("If an account with that email exists") + email_producer_mock.send_email.assert_not_awaited() diff --git a/tests/test_auth/test_verification_token_crud.py b/tests/test_auth/test_verification_token_crud.py new file mode 100644 index 0000000..4b067d0 --- /dev/null +++ b/tests/test_auth/test_verification_token_crud.py @@ -0,0 +1,117 @@ +from collections.abc import Generator +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.crud.user.user import create_user +from app.crud.verification_token import verification_token_repository +from app.models.user import Base +from app.models.verification_token import VerificationToken +from app.schemas.user import SupportedLanguage, UserCreate + + +def _as_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _new_user_payload(email: str) -> UserCreate: + return UserCreate( + email=email, + password="MyStr0ngP@ss!", + full_name="Token User", + speaking_language=SupportedLanguage.ENGLISH, + listening_language=SupportedLanguage.FRENCH, + ) + + +def _create_user(db: Session, email: str) -> int: + user = create_user(db=db, user_in=_new_user_payload(email=email)) + return user.id + + +def _force_token_expiry(db: Session, token_id: int) -> None: + token = db.get(VerificationToken, token_id) + if token is None: + return + token.expires_at = datetime.now(UTC) - timedelta(hours=1) + db.commit() + + +@pytest.fixture +def db_session() -> Generator[Session, None, None]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + testing_session_local = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + ) + Base.metadata.create_all(bind=engine) + db = testing_session_local() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +def test_create_token_persists_token(db_session: Session) -> None: + user_id = _create_user(db=db_session, email="crud-create@example.com") + token = verification_token_repository.create_token(db=db_session, user_id=user_id) + + assert token.id is not None + assert token.user_id == user_id + assert token.token + assert _as_aware_utc(token.expires_at) > datetime.now(UTC) + + +def test_get_token_returns_matching_row(db_session: Session) -> None: + user_id = _create_user(db=db_session, email="crud-get@example.com") + created = verification_token_repository.create_token(db=db_session, user_id=user_id) + + found = verification_token_repository.get_token(db=db_session, token=created.token) + + assert found is not None + assert found.id == created.id + + +def test_delete_token_removes_row(db_session: Session) -> None: + user_id = _create_user(db=db_session, email="crud-delete@example.com") + created = verification_token_repository.create_token(db=db_session, user_id=user_id) + + verification_token_repository.delete_token(db=db_session, token_id=created.id) + + statement = select(VerificationToken).where(VerificationToken.id == created.id) + assert db_session.execute(statement).scalar_one_or_none() is None + + +def test_delete_unexpired_tokens_for_user_keeps_expired_tokens( + db_session: Session, +) -> None: + user_id = _create_user(db=db_session, email="crud-prune@example.com") + token = verification_token_repository.create_token(db=db_session, user_id=user_id) + _force_token_expiry(db=db_session, token_id=token.id) + second = verification_token_repository.create_token(db=db_session, user_id=user_id) + + verification_token_repository.delete_unexpired_tokens_for_user( + db=db_session, + user_id=user_id, + ) + + assert ( + verification_token_repository.get_token(db=db_session, token=second.token) + is None + ) + assert ( + verification_token_repository.get_token(db=db_session, token=token.token) + is not None + ) From 6428617844e5f303a0a8ae1feec1e772d67875a0 Mon Sep 17 00:00:00 2001 From: aniebietafia Date: Wed, 18 Mar 2026 00:24:59 +0100 Subject: [PATCH 2/2] feat(auth): add email verification and resend verification flow add VerificationToken model and migration for persistent token storage implement class-based verification logic via VerificationTokenRepository and AuthVerificationService add GET /api/v1/auth/verify-email with token validation, expiry handling, and idempotent verified-user behavior add POST /api/v1/auth/resend-verification with enumeration-safe response and 3/minute rate limit integrate signup to generate verification tokens and enqueue email dispatch via Kafka email producer add standardized 429 error handling through custom rate-limit exception handler document endpoints in docs/auth_verification_api.md add tests for verification CRUD and API flows (success, missing token, invalid token, expired token, resend cases) keep code quality gates compliant (ruff, isort, mypy) and auth tests green Signed-off-by: aniebietafia --- {docs => app/crud}/auth_verification_api.md | 0 issue.md | 73 --------------------- 2 files changed, 73 deletions(-) rename {docs => app/crud}/auth_verification_api.md (100%) delete mode 100644 issue.md diff --git a/docs/auth_verification_api.md b/app/crud/auth_verification_api.md similarity index 100% rename from docs/auth_verification_api.md rename to app/crud/auth_verification_api.md diff --git a/issue.md b/issue.md deleted file mode 100644 index bd27abb..0000000 --- a/issue.md +++ /dev/null @@ -1,73 +0,0 @@ -### Feature: Implement GET /api/v1/auth/verify-email — Email Verification Endpoint - -**Problem** -After signup, new user accounts are created with `is_verified=False`. Without an email verification endpoint, there is no way for users to activate their accounts, and the `is_verified` flag serves no purpose. Unverified users are blocked from logging in (enforced in the `/login` endpoint), creating a dead end if the verification link cannot be processed. - -**Proposed Solution** -Implement `GET /api/v1/auth/verify-email?token=` which looks up the verification token in the database, checks it is valid and unexpired, marks the user as verified (`is_verified=True`), and invalidates the token to prevent reuse. Since this endpoint is accessed by clicking a link in an email, it must be a `GET` request with the token as a query parameter. - -**User Stories** -* **As a new user,** I want to click the link in my verification email and have my account immediately activated, so I can log in without any further steps. -* **As a new user,** I want to see a clear error if my verification link has expired, with guidance on how to request a new one, so I am not left confused with an inactive account. -* **As a security engineer,** I want each verification token to be single-use and time-limited, so that a leaked or intercepted verification link cannot be used to verify an account it was not sent to. - -**Acceptance Criteria** -1. `GET /api/v1/auth/verify-email?token=` is a public endpoint (no authentication required). -2. **Token Lookup & Validation**: - * If the `token` query parameter is absent, return `400 Bad Request`: - ```json - { "status": "error", "code": "MISSING_TOKEN", "message": "Verification token is required.", "details": [] } - ``` - * If no matching `VerificationToken` record is found, return `400 Bad Request`: - ```json - { "status": "error", "code": "INVALID_TOKEN", "message": "Verification token is invalid.", "details": [] } - ``` - * If the token exists but `expires_at < now()`, return `400 Bad Request`: - ```json - { "status": "error", "code": "TOKEN_EXPIRED", "message": "Verification token has expired. Please request a new one.", "details": [] } - ``` - * If the token's associated user is already verified (`is_verified=True`), return `200 OK` idempotently — do not treat this as an error. -3. **On Valid Token**: - * Set `user.is_verified = True` and `user.updated_at = now()` in the database. - * Delete the `VerificationToken` record to prevent reuse. - * Both operations are performed in a single atomic database transaction. -4. On success, return `200 OK`: - ```json - { "status": "ok", "message": "Email successfully verified. You can now log in." } - ``` -5. A **resend verification** endpoint (`POST /api/v1/auth/resend-verification`) is implemented alongside this one, allowing users with expired tokens to request a new verification email. It: - * Accepts `{ "email": "user@example.com" }`. - * Deletes any existing unexpired token for the user before generating a new one. - * Is rate-limited to **3 requests/minute** per IP to prevent email flooding. - * Always returns `200 OK` regardless of whether the email exists, to prevent user enumeration. -6. Unit and integration tests cover: valid token, invalid token, expired token, already-verified user (idempotent), and resend flow. - -**Proposed Technical Details** -* **Router**: `app/api/v1/endpoints/auth.py` — new `GET /verify-email` and `POST /resend-verification` routes. -* **Token Model**: `VerificationToken` (created in the [auth_signup issue](./auth_signup.md)) — fields: `id`, `user_id` (FK), `token` (UUID, unique, indexed), `expires_at` (default: `now() + 24h`), `created_at`. -* **CRUD** in `app/crud/verification_token.py`: - * `get_token(db, token: str) -> VerificationToken | None` - * `delete_token(db, token_id: int) -> None` - * `create_token(db, user_id: int) -> VerificationToken` -* **Email Trigger for Resend**: Publishes to `notifications.email` Kafka topic (same as signup) with `template: "verification"`. -* **Atomic Transaction**: `user.is_verified = True` and `delete_token` are wrapped in a single `db.begin()` / `db.commit()` block to ensure consistency if either fails. -* **New/Modified Files**: - * `app/api/v1/endpoints/auth.py` — add `GET /verify-email`, `POST /resend-verification` [MODIFY] - * `app/crud/verification_token.py` — token CRUD operations [NEW] - -**Tasks** -- [ ] Implement `get_token`, `delete_token`, and `create_token` in `app/crud/verification_token.py`. -- [ ] Implement `GET /api/v1/auth/verify-email` in `app/api/v1/endpoints/auth.py`. -- [ ] Ensure `user.is_verified = True` and token deletion are wrapped in a single atomic transaction. -- [ ] Handle already-verified user idempotently (return `200` without error). -- [ ] Implement `POST /api/v1/auth/resend-verification` with email enumeration protection. -- [ ] Apply `@limiter.limit("3/minute")` to the resend endpoint. -- [ ] Integrate `EmailProducerService` in the resend flow to publish to `notifications.email`. -- [ ] Write unit tests for all `verification_token` CRUD functions. -- [ ] Write integration tests: valid token, invalid token, expired token, already verified (idempotent), and resend flow. - -**Open Questions/Considerations** -* Should a successfully verified user be automatically logged in and receive tokens in the `/verify-email` response, or should they be redirected to the login page to authenticate separately? -* Should the verification link redirect to a frontend URL (e.g., `https://app.fluentmeet.com/verified`) rather than returning JSON, since this endpoint is opened in a browser? -* Should the verification token expiry be configurable via settings (`VERIFICATION_TOKEN_EXPIRE_HOURS`), or fixed at 24 hours? -* If the user never verifies their email, should we schedule an automatic cleanup job to purge unverified accounts older than a threshold (e.g., 7 days)?