-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): add email verification and resend verification flow #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0647701
6428617
cc2bef8
1c0b9bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,57 @@ | ||||||||||||||||||||||||||||||||
| """Add email verification model | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Revision ID: 19dc9714d9ea | ||||||||||||||||||||||||||||||||
| Revises: 11781e907181 | ||||||||||||||||||||||||||||||||
| Create Date: 2026-03-19 12:22:16.244801 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| from typing import Sequence, Union | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| from alembic import op | ||||||||||||||||||||||||||||||||
| import sqlalchemy as sa | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # revision identifiers, used by Alembic. | ||||||||||||||||||||||||||||||||
| revision: str = "19dc9714d9ea" | ||||||||||||||||||||||||||||||||
| down_revision: Union[str, Sequence[str], None] = "11781e907181" | ||||||||||||||||||||||||||||||||
Check noticeCode scanning / CodeQL Unused global variable Note
The global variable 'down_revision' is not used.
Copilot AutofixAI about 4 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: Union[str, Sequence[str], None] = None | ||||||||||||||||||||||||||||||||
Check noticeCode scanning / CodeQL Unused global variable Note
The global variable 'branch_labels' is not used.
Copilot AutofixAI about 4 hours ago To fix the reported issue without changing runtime behavior, we should indicate that Concretely, in
Suggested changeset
1
alembic/versions/19dc9714d9ea_add_email_verification_model.py
Copilot is powered by AI and may make mistakes. Always verify output.
Refresh and try again.
|
||||||||||||||||||||||||||||||||
| depends_on: Union[str, Sequence[str], None] = None | ||||||||||||||||||||||||||||||||
Check noticeCode scanning / CodeQL Unused global variable Note
The global variable 'depends_on' is not used.
Copilot AutofixAI about 4 hours ago To fix the problem while preserving existing functionality, we should keep the assignment (so the information and any potential Alembic expectations remain) but rename the variable so that its name clearly indicates it is intentionally unused and passes the static-analysis rule. The right-hand side is a simple literal ( The best minimal change in this file is to rename Concretely:
Suggested changeset
1
alembic/versions/19dc9714d9ea_add_email_verification_model.py
Copilot is powered by AI and may make mistakes. Always verify output.
Refresh and try again.
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def upgrade() -> None: | ||||||||||||||||||||||||||||||||
| """Upgrade schema.""" | ||||||||||||||||||||||||||||||||
| # ### commands auto generated by Alembic - please adjust! ### | ||||||||||||||||||||||||||||||||
| 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
+37
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.pyRepository: Brints/FluentMeet Length of output: 1916 Replace redundant The index on Replace the 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 |
||||||||||||||||||||||||||||||||
| # ### end Alembic commands ### | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def downgrade() -> None: | ||||||||||||||||||||||||||||||||
| """Downgrade schema.""" | ||||||||||||||||||||||||||||||||
| # ### commands auto generated by Alembic - please adjust! ### | ||||||||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||||||||
| # ### end Alembic commands ### | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,34 @@ | ||
| 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.core.sanitize import sanitize_log_args | ||
| 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( | ||
|
|
@@ -31,11 +40,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( | ||
|
|
@@ -47,8 +63,11 @@ async def signup( | |
| ) | ||
| except Exception as exc: | ||
| # Signup should succeed even if email queueing fails. | ||
| user_id_safe, exc_safe = sanitize_log_args(user.id, exc) | ||
| logger.warning( | ||
| "Failed to enqueue verification email for user %s: %s", user.id, exc | ||
| "Failed to enqueue verification email for user %s: %s", | ||
| user_id_safe, | ||
| exc_safe, | ||
| ) | ||
|
|
||
| return SignupResponse.model_validate(user) | ||
|
|
@@ -81,8 +100,11 @@ async def forgot_password( | |
| template="password_reset", | ||
| ) | ||
| except Exception as exc: | ||
| email_safe, exc_safe = sanitize_log_args(user.email, exc) | ||
| logger.warning( | ||
| "Failed to enqueue password reset email for %s: %s", user.email, exc | ||
| "Failed to enqueue password reset email for %s: %s", | ||
| email_safe, | ||
| exc_safe, | ||
| ) | ||
|
|
||
| return ActionAcknowledgement( | ||
|
|
@@ -91,3 +113,106 @@ 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: | ||
| email_safe, exc_safe = sanitize_log_args(payload.email, exc) | ||
| logger.warning( | ||
| "Failed to enqueue verification resend for %s: %s", | ||
| email_safe, | ||
| exc_safe, | ||
| ) | ||
|
Comment on lines
+200
to
+212
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't mask token/database failures as a successful resend.
🧰 Tools🪛 GitHub Check: CodeQL[failure] 201-201: Log Injection 🤖 Prompt for AI Agents |
||
|
|
||
| return ActionAcknowledgement( | ||
| message=( | ||
| "If an account with that email exists, we have sent a verification email." | ||
| ) | ||
| ) | ||
| 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.", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import re | ||
| from collections.abc import Iterable | ||
|
|
||
|
|
||
| class LogSanitizer: | ||
| """Sanitizes user-controlled values before they are written to logs.""" | ||
|
|
||
| _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") | ||
|
|
||
| def __init__(self, max_length: int = 256) -> None: | ||
| self._max_length = max_length | ||
|
|
||
| def sanitize(self, value: object) -> str: | ||
| text = str(value) | ||
| text = text.replace("\r", r"\r").replace("\n", r"\n").replace("\t", r"\t") | ||
| text = self._CONTROL_CHARS.sub("?", text) | ||
| if len(text) <= self._max_length: | ||
| return text | ||
|
|
||
| return f"{text[: self._max_length]}...<truncated>" | ||
|
|
||
| def sanitize_many(self, values: Iterable[object]) -> tuple[str, ...]: | ||
| return tuple(self.sanitize(value) for value in values) | ||
|
|
||
|
|
||
| log_sanitizer = LogSanitizer() | ||
|
|
||
|
|
||
| def sanitize_for_log(value: object) -> str: | ||
| return log_sanitizer.sanitize(value) | ||
|
|
||
|
|
||
| def sanitize_log_args(*values: object) -> tuple[str, ...]: | ||
| return log_sanitizer.sanitize_many(values) |
Check notice
Code scanning / CodeQL
Unused global variable Note
Copilot Autofix
AI about 4 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.