Skip to content
Open
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
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"

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'revision' is not used.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

down_revision: str | Sequence[str] | None = "11781e907181"

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'down_revision' is not used.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

branch_labels: str | Sequence[str] | None = None

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'branch_labels' is not used.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

depends_on: str | Sequence[str] | None = None

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'depends_on' is not used.

Copilot Autofix

AI about 20 hours ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.



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,
)
Comment on lines +32 to +43
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify repository query patterns that filter by user_id/expires_at for verification tokens.
fd 'verification_token\.py$' app tests | xargs -r rg -n -C2 'user_id|expires_at|delete_unexpired_tokens_for_user|get_token\('

Repository: Brints/FluentMeet

Length of output: 3144


🏁 Script executed:

cat -n alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py

Repository: Brints/FluentMeet

Length of output: 1916


Replace redundant id index with composite index supporting user-based token cleanup.

The index on id at line 32 is redundant—primary keys automatically have index support. More critically, the delete_unexpired_tokens_for_user() method in app/crud/verification_token.py:32-37 filters by both user_id and expires_at, but no index covers this pattern, causing full table scans as data grows.

Replace the id index with a composite index on (user_id, expires_at):

Suggested migration adjustment
 def upgrade() -> None:
     op.create_index(
-        op.f("ix_verification_tokens_id"),
+        op.f("ix_verification_tokens_user_id_expires_at"),
         "verification_tokens",
-        ["id"],
+        ["user_id", "expires_at"],
         unique=False,
     )

 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.f("ix_verification_tokens_user_id_expires_at"), table_name="verification_tokens"
     )
     op.drop_table("verification_tokens")

Also applies to: 47-50

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

In `@alembic/versions/4b4b6b5d1c2a_add_verification_tokens_table.py` around lines
32 - 43, Replace the redundant single-column index ix_verification_tokens_id
with a composite index on (user_id, expires_at) so queries like
delete_unexpired_tokens_for_user (in app/crud/verification_token.py) can use an
index; specifically, remove the op.create_index call that creates
ix_verification_tokens_id and instead add
op.create_index(op.f("ix_verification_tokens_user_id_expires_at"),
"verification_tokens", ["user_id", "expires_at"], unique=False). Make the same
replacement for the corresponding index in the later block (the second
occurrence around lines 47-50) so both upgrade and downgrade/other index
definitions reflect the composite index. Ensure ix_verification_tokens_token
(unique on token) remains unchanged.



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

Check failure

Code scanning / CodeQL

Log Injection High

This log entry depends on a
user-provided value
.

Copilot Autofix

AI about 20 hours ago

In general, to fix log injection, you should sanitize any user-controlled values before including them in log messages, especially by removing or neutralizing newline and carriage-return characters so a user cannot split or visually manipulate log entries. This is typically done by replacing \r and \n with empty strings or safe placeholders on a local variable that you only use for logging, so you do not alter the original data used for application logic.

For this specific code, the best minimal fix is to sanitize payload.email right before logging it in the except block of resend_verification. We will introduce a local variable, e.g. safe_email, that converts payload.email to str and strips \r and \n characters using .replace('\r', '').replace('\n', ''). We then pass safe_email to logger.warning instead of the raw payload.email. This keeps the functional behavior (resend logic, response) unchanged and only affects what is recorded in logs. No new imports or helpers are strictly necessary; the sanitization can be an inline transformation on that one line.

Concretely:

  • Edit app/api/v1/endpoints/auth.py within the resend_verification function.
  • In the except Exception as exc: block, before logger.warning(...), define safe_email = str(payload.email).replace('\r', '').replace('\n', '').
  • Change the logger.warning call to use safe_email as the first %s argument.
  • Leave all other behavior intact.
Suggested changeset 1
app/api/v1/endpoints/auth.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py
--- a/app/api/v1/endpoints/auth.py
+++ b/app/api/v1/endpoints/auth.py
@@ -197,8 +197,9 @@
             email_producer=email_producer,
         )
     except Exception as exc:
+        safe_email = str(payload.email).replace("\r", "").replace("\n", "")
         logger.warning(
-            "Failed to enqueue verification resend for %s: %s", payload.email, exc
+            "Failed to enqueue verification resend for %s: %s", safe_email, exc
         )
 
     return ActionAcknowledgement(
EOF
@@ -197,8 +197,9 @@
email_producer=email_producer,
)
except Exception as exc:
safe_email = str(payload.email).replace("\r", "").replace("\n", "")
logger.warning(
"Failed to enqueue verification resend for %s: %s", payload.email, exc
"Failed to enqueue verification resend for %s: %s", safe_email, exc
)

return ActionAcknowledgement(
Copilot is powered by AI and may make mistakes. Always verify output.
)
Comment on lines +193 to +202
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