Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py
Original file line number Diff line number Diff line change
@@ -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")
120 changes: 118 additions & 2 deletions app/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
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__)

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(
Expand All @@ -31,11 +39,18 @@
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(
Expand Down Expand Up @@ -91,3 +106,104 @@
"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
)
Comment on lines +200 to +212
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't mask token/database failures as a successful resend.

resend_verification_email() does user lookup and token mutation before it touches the email producer. This except Exception converts failures in those DB steps into a 200 "If an account..." response, which hides outages and can leave state partially changed. Catch only the producer failure path, or let the persistence errors propagate.

🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 201-201: Log Injection
This log entry depends on a user-provided value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/v1/endpoints/auth.py` around lines 193 - 202, The current broad
except around auth_verification_service.resend_verification_email masks DB/token
failures; narrow the error handling so persistence errors propagate and only
email-producer failures are caught. Move the try/except so it surrounds just the
call to the email_producer (or have resend_verification_email raise a specific
EmailProducerError), catch that specific exception (e.g., EmailProducerError or
the producer's raised exception) and log via logger.warning("Failed to enqueue
verification resend for %s: %s", payload.email, exc); do not catch Exception
from within the overall resend_verification_email call so lookup/token mutation
errors surface as failures.


return ActionAcknowledgement(
message=(
"If an account with that email exists, we have sent a verification "
"email."
)
)
1 change: 1 addition & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions app/core/rate_limiter.py
Original file line number Diff line number Diff line change
@@ -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.",
)
83 changes: 83 additions & 0 deletions app/crud/auth_verification_api.md
Original file line number Diff line number Diff line change
@@ -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`.
57 changes: 57 additions & 0 deletions app/crud/verification_token.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +15 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Let resend own the transaction boundary.

In app/services/auth_verification.py, Lines 87-88 call these helpers back-to-back during resend. Because both methods commit independently, a failure between them can revoke the user's current valid token before the replacement exists. Expose no-commit variants or move the single commit() up to the service layer.

Also applies to: 32-42

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/crud/verification_token.py` around lines 15 - 23, The create_token method
(and the similar revoke/current-token helper at lines 32-42) commits
independently which can cause a gap when called back-to-back from
app/services/auth_verification.py; change these helpers to support a no-commit
mode (e.g. add an optional commit: bool = True parameter or provide internal
variants like _create_token_no_commit and _revoke_current_tokens_no_commit) so
they perform db.add/db.flush/db.refresh without db.commit when commit is False,
and then update the service layer to call both helpers and perform a single
db.commit() after both have succeeded; reference the create_token function and
the revoke_current_tokens helper when implementing this change.


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)
Loading
Loading