diff --git a/.env.example b/.env.example index 0a4c747..562b9c2 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ POSTGRES_SERVER=localhost POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=fluentmeet -DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/fluentmeet" +DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/fluentmeet # Redis REDIS_HOST=localhost diff --git a/README.md b/README.md index 7774be9..e4b5c05 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Ensure your models are imported in `app/models/init.py` for Alembic to detect th ```bash python -m alembic revision --autogenerate -m "Add Meeting model" ``` +### **Migrate Head** +```bash +alembic upgrade head +``` ### **Applying Migrations** ```bash diff --git a/alembic/versions/7f066a8213a8_change_user_id_to_uuid.py b/alembic/versions/7f066a8213a8_change_user_id_to_uuid.py new file mode 100644 index 0000000..548da7b --- /dev/null +++ b/alembic/versions/7f066a8213a8_change_user_id_to_uuid.py @@ -0,0 +1,73 @@ +"""Change User.id to UUID + +Revision ID: 7f066a8213a8 +Revises: e1a664780dc6 +Create Date: 2026-03-27 22:35:06.005762 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "7f066a8213a8" +down_revision: Union[str, Sequence[str], None] = "e1a664780dc6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # Setup extension for UUID generation if needed + op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + + # We truncate because casting int -> uuid with a random function would break existing foreign key relations. + op.execute("TRUNCATE TABLE users CASCADE") + + # Drop foreign key constraint on verification_tokens + op.drop_constraint( + "verification_tokens_user_id_fkey", "verification_tokens", type_="foreignkey" + ) + + # Alter the columns + # Alter the columns. First drop the integer default from the id column to allow cast. + op.execute("ALTER TABLE users ALTER COLUMN id DROP DEFAULT") + op.execute( + "ALTER TABLE users ALTER COLUMN id SET DATA TYPE UUID USING (uuid_generate_v4())" + ) + op.execute( + "ALTER TABLE verification_tokens ALTER COLUMN user_id SET DATA TYPE UUID USING (uuid_generate_v4())" + ) + + # Re-add foreign key constraint + op.create_foreign_key( + "verification_tokens_user_id_fkey", + "verification_tokens", + "users", + ["user_id"], + ["id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "verification_tokens", + "user_id", + existing_type=sa.Uuid(), + type_=sa.INTEGER(), + existing_nullable=False, + ) + op.alter_column( + "users", + "id", + existing_type=sa.Uuid(), + type_=sa.INTEGER(), + existing_nullable=False, + ) + # ### end Alembic commands ### diff --git a/alembic/versions/e1a664780dc6_add_meeting_model.py b/alembic/versions/e1a664780dc6_add_meeting_model.py new file mode 100644 index 0000000..273b549 --- /dev/null +++ b/alembic/versions/e1a664780dc6_add_meeting_model.py @@ -0,0 +1,32 @@ +"""Add Meeting model + +Revision ID: e1a664780dc6 +Revises: 19dc9714d9ea +Create Date: 2026-03-27 22:18:35.070453 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "e1a664780dc6" +down_revision: Union[str, Sequence[str], None] = "19dc9714d9ea" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic_output.txt b/alembic_output.txt new file mode 100644 index 0000000..f9858bf --- /dev/null +++ b/alembic_output.txt @@ -0,0 +1,3 @@ +INFO [alembic.runtime.migration] Context impl PostgresqlImpl. +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.runtime.migration] Running upgrade 19dc9714d9ea -> e1a664780dc6, Add Meeting model diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py index 4f4f3cd..6f055d4 100644 --- a/app/api/v1/endpoints/auth.py +++ b/app/api/v1/endpoints/auth.py @@ -2,26 +2,36 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, Query, Request, status +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.core.config import settings +from app.core.exceptions import ForbiddenException, UnauthorizedException from app.core.rate_limiter import limiter from app.core.sanitize import sanitize_log_args +from app.core.security import SecurityService, get_security_service 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, + LoginRequest, + LoginResponse, ResendVerificationRequest, SignupResponse, VerifyEmailResponse, ) from app.schemas.user import UserCreate +from app.services.account_lockout import ( + AccountLockoutService, + get_account_lockout_service, +) from app.services.auth_verification import ( AuthVerificationService, get_auth_verification_service, ) from app.services.email_producer import EmailProducerService, get_email_producer_service +from app.services.token_store import TokenStoreService, get_token_store_service logger = logging.getLogger(__name__) @@ -29,6 +39,9 @@ DB_SESSION_DEPENDENCY = Depends(get_db) EMAIL_PRODUCER_DEPENDENCY = Depends(get_email_producer_service) AUTH_VERIFICATION_SERVICE_DEPENDENCY = Depends(get_auth_verification_service) +SECURITY_SERVICE_DEPENDENCY = Depends(get_security_service) +TOKEN_STORE_SERVICE_DEPENDENCY = Depends(get_token_store_service) +ACCOUNT_LOCKOUT_SERVICE_DEPENDENCY = Depends(get_account_lockout_service) @router.post( @@ -216,3 +229,160 @@ async def resend_verification( "If an account with that email exists, we have sent a verification email." ) ) + + +@router.post( + "/login", + response_model=LoginResponse, + status_code=status.HTTP_200_OK, + summary="Authenticate a registered user", + description=( + "Validates email and password, issues a JWT access token (returned " + "in the body) and a JWT refresh token (set as an HttpOnly cookie). " + "Rate-limited to 10 requests/minute per IP. The account is locked " + "after 5 consecutive failed attempts for 5 days." + ), + responses={ + 401: { + "description": "Invalid credentials", + "content": { + "application/json": { + "example": { + "status": "error", + "code": "INVALID_CREDENTIALS", + "message": "Invalid email or password.", + "details": [], + } + } + }, + }, + 403: { + "description": "Account not verified, deleted, or locked", + "content": { + "application/json": { + "examples": { + "not_verified": { + "value": { + "status": "error", + "code": "EMAIL_NOT_VERIFIED", + "message": ( + "Please verify your email before logging in." + ), + "details": [], + } + }, + "deleted": { + "value": { + "status": "error", + "code": "ACCOUNT_DELETED", + "message": "This account has been deleted.", + "details": [], + } + }, + "locked": { + "value": { + "status": "error", + "code": "ACCOUNT_LOCKED", + "message": ( + "Account is temporarily locked due to " + "too many failed login attempts. " + "Please try again later." + ), + "details": [], + } + }, + } + } + }, + }, + }, +) +@limiter.limit("10/minute") +async def login( + request: Request, + payload: LoginRequest, + db: Session = DB_SESSION_DEPENDENCY, + security_svc: SecurityService = SECURITY_SERVICE_DEPENDENCY, + token_store: TokenStoreService = TOKEN_STORE_SERVICE_DEPENDENCY, + lockout_svc: AccountLockoutService = ACCOUNT_LOCKOUT_SERVICE_DEPENDENCY, +) -> JSONResponse: + del request # consumed by slowapi + email = payload.email.lower() + + # Check lockout + if await lockout_svc.is_locked(email): + raise ForbiddenException( + code="ACCOUNT_LOCKED", + message=( + "Account is temporarily locked due to too many failed " + "login attempts. Please try again later." + ), + ) + + # Lookup user + user = get_user_by_email(db, email) + if user is None: + # Record a failed attempt even for non-existent emails so that + # timing is indistinguishable from a wrong-password attempt. + await lockout_svc.record_failed_attempt(email) + raise UnauthorizedException( + code="INVALID_CREDENTIALS", + message="Invalid email or password.", + ) + + # Verify password + if not security_svc.verify_password(payload.password, user.hashed_password): + await lockout_svc.record_failed_attempt(email) + raise UnauthorizedException( + code="INVALID_CREDENTIALS", + message="Invalid email or password.", + ) + + # Guard: email verified? + if not user.is_verified: + raise ForbiddenException( + code="EMAIL_NOT_VERIFIED", + message="Please verify your email before logging in.", + ) + + # Guard: soft-deleted? + if user.deleted_at is not None: + raise ForbiddenException( + code="ACCOUNT_DELETED", + message="This account has been deleted.", + ) + + # Reset failed-login counter on success + await lockout_svc.reset_attempts(email) + + # Issue tokens + access_token, expires_in = security_svc.create_access_token(email=email) + refresh_token, refresh_jti, refresh_ttl = security_svc.create_refresh_token( + email=email, + ) + + # Persist refresh JTI in Redis + await token_store.save_refresh_token(jti=refresh_jti, ttl_seconds=refresh_ttl) + + # Build JSON body + body = LoginResponse( + access_token=access_token, + user_id=user.id, + token_type="bearer", + expires_in=expires_in, + ) + + response = JSONResponse(content=body.model_dump(mode="json"), status_code=200) + + # Set HttpOnly refresh-token cookie + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict", + path=f"{settings.API_V1_STR}/auth", + max_age=refresh_ttl, + ) + + return response diff --git a/app/core/config.py b/app/core/config.py index ee18617..7455256 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,10 +20,15 @@ class Settings(BaseSettings): # Security SECRET_KEY: str = "placeholder_secret_key" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 VERIFICATION_TOKEN_EXPIRE_HOURS: int = 24 + # Account Lockout + MAX_FAILED_LOGIN_ATTEMPTS: int = 5 + ACCOUNT_LOCKOUT_DAYS: int = 5 + # Database POSTGRES_SERVER: str = "localhost" POSTGRES_USER: str = "postgres" diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..dccbba8 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,122 @@ +"""Security utilities for password hashing and JWT token management.""" + +from datetime import UTC, datetime, timedelta +from typing import Any, cast +from uuid import uuid4 + +import bcrypt +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + + +class SecurityService: + """Centralised service for password operations and JWT token creation. + + Attributes: + pwd_context: passlib CryptContext configured for bcrypt hashing. + """ + + def __init__(self) -> None: + self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + # ------------------------------------------------------------------ + # Password helpers + # ------------------------------------------------------------------ + + def hash_password(self, password: str) -> str: + """Hash *password* using bcrypt. + + Falls back to raw ``bcrypt`` if passlib's backend probing fails + (common with newer bcrypt builds). + """ + try: + return cast(str, self.pwd_context.hash(password)) + except ValueError: + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + + def verify_password(self, plain_password: str, hashed_password: str) -> bool: + """Return ``True`` when *plain_password* matches *hashed_password*. + + Falls back to raw ``bcrypt.checkpw`` when passlib's backend + probing fails (same compatibility issue as :meth:`hash_password`). + """ + try: + return bool(self.pwd_context.verify(plain_password, hashed_password)) + except (ValueError, TypeError, AttributeError): + try: + return bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8"), + ) + except Exception: + return False + + # ------------------------------------------------------------------ + # JWT helpers + # ------------------------------------------------------------------ + + def create_access_token( + self, + email: str, + jti: str | None = None, + ) -> tuple[str, int]: + """Create a short-lived JWT access token. + + Returns: + A ``(token, expires_in_seconds)`` tuple. + """ + jti = jti or str(uuid4()) + expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(UTC) + expires_delta + + payload: dict[str, Any] = { + "sub": email, + "jti": jti, + "exp": expire, + "type": "access", + } + token = jwt.encode( + payload, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM, + ) + return token, int(expires_delta.total_seconds()) + + def create_refresh_token( + self, + email: str, + jti: str | None = None, + ) -> tuple[str, str, int]: + """Create a long-lived JWT refresh token. + + Returns: + A ``(token, jti, ttl_seconds)`` tuple. + """ + jti = jti or str(uuid4()) + ttl_seconds = settings.REFRESH_TOKEN_EXPIRE_DAYS * 86400 + expire = datetime.now(UTC) + timedelta(seconds=ttl_seconds) + + payload: dict[str, Any] = { + "sub": email, + "jti": jti, + "exp": expire, + "type": "refresh", + } + token = jwt.encode( + payload, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM, + ) + return token, jti, ttl_seconds + + +# Module-level singleton ----------------------------------------------- +security_service = SecurityService() + + +def get_security_service() -> SecurityService: + """FastAPI dependency that returns the module-level SecurityService.""" + return security_service diff --git a/app/crud/user/user.py b/app/crud/user/user.py index 8abacb9..d4f1f2e 100644 --- a/app/crud/user/user.py +++ b/app/crud/user/user.py @@ -1,25 +1,11 @@ -from typing import cast - -import bcrypt -from passlib.context import CryptContext from sqlalchemy import select from sqlalchemy.orm import Session from app.core.exceptions import ConflictException +from app.core.security import security_service from app.models.user import User from app.schemas.user import UserCreate -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def hash_password(password: str) -> str: - try: - return cast(str, pwd_context.hash(password)) - except ValueError: - # Passlib's bcrypt backend probing can fail with newer bcrypt builds. - salt = bcrypt.gensalt() - return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") - def get_user_by_email(db: Session, email: str) -> User | None: statement = select(User).where(User.email == email.lower()) @@ -36,7 +22,7 @@ def create_user(db: Session, user_in: UserCreate) -> User: db_user = User( email=user_in.email.lower(), - hashed_password=hash_password(user_in.password), + hashed_password=security_service.hash_password(user_in.password), full_name=user_in.full_name, speaking_language=user_in.speaking_language.value, listening_language=user_in.listening_language.value, diff --git a/app/crud/verification_token.py b/app/crud/verification_token.py index cf46a1c..c6f965b 100644 --- a/app/crud/verification_token.py +++ b/app/crud/verification_token.py @@ -1,3 +1,4 @@ +import uuid from datetime import UTC, datetime, timedelta from sqlalchemy import select @@ -22,7 +23,7 @@ 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: + def create_token(self, db: Session, user_id: uuid.UUID) -> VerificationToken: expires_at = datetime.now(UTC) + timedelta( hours=settings.VERIFICATION_TOKEN_EXPIRE_HOURS ) @@ -39,7 +40,7 @@ def delete_token(self, db: Session, token_id: int) -> None: db.delete(token) db.commit() - def delete_unexpired_tokens_for_user(self, db: Session, user_id: int) -> None: + def delete_unexpired_tokens_for_user(self, db: Session, user_id: uuid.UUID) -> None: now = datetime.now(UTC) statement = select(VerificationToken).where( VerificationToken.user_id == user_id, @@ -59,7 +60,7 @@ 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: +def create_token(db: Session, user_id: uuid.UUID) -> VerificationToken: return verification_token_repository.create_token(db=db, user_id=user_id) diff --git a/app/db/session.py b/app/db/session.py index 2132818..773ceec 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,3 +1,4 @@ +import logging from collections.abc import Generator from typing import Final @@ -7,8 +8,35 @@ from app.core.config import settings +logger = logging.getLogger(__name__) + DEFAULT_SQLITE_URL: Final[str] = "sqlite:///./fluentmeet.db" -DATABASE_URL = settings.DATABASE_URL or DEFAULT_SQLITE_URL + + +def _coerce_sync_url(url: str) -> str: + """Replace the async ``asyncpg`` driver with sync ``psycopg2``. + + The application uses synchronous SQLAlchemy (``create_engine`` + + ``Session``), so the ``asyncpg`` DBAPI - which requires + ``create_async_engine`` - will fail at runtime with a + ``MissingGreenlet`` error. This helper silently swaps the driver + so that the connection string from ``.env`` works out of the box. + """ + if "+asyncpg" in url: + fixed = url.replace("+asyncpg", "+psycopg2") + logger.info( + "Replaced async driver 'asyncpg' with sync driver 'psycopg2' " + "in DATABASE_URL." + ) + return fixed + return url + + +DATABASE_URL = ( + _coerce_sync_url(settings.DATABASE_URL) + if settings.DATABASE_URL + else DEFAULT_SQLITE_URL +) _ENGINE_STATE: dict[str, Engine] = {} SessionLocal = sessionmaker(autoflush=False, autocommit=False) @@ -24,6 +52,7 @@ def get_engine() -> Engine: if DATABASE_URL.startswith("postgresql") and exc.name in { "psycopg2", "psycopg", + "asyncpg", }: cached_engine = create_engine(DEFAULT_SQLITE_URL, pool_pre_ping=True) else: diff --git a/app/kafka/schemas.py b/app/kafka/schemas.py index 0ee09a7..52f9c28 100644 --- a/app/kafka/schemas.py +++ b/app/kafka/schemas.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime from typing import Any, Generic, TypeVar -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field T = TypeVar("T") @@ -44,8 +44,13 @@ class EmailEvent(BaseEvent[EmailPayload]): event_type: str = "email.dispatch" +class UserRegisteredEvent(BaseEvent): + user_id: uuid.UUID + email: EmailStr + + class MediaUploadPayload(BaseModel): - user_id: int + user_id: uuid.UUID file_path: str file_type: str # e.g., 'avatar', 'recording' metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/app/models/user.py b/app/models/user.py index 542fce3..a3e6fe2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,3 +1,4 @@ +import uuid from datetime import UTC, datetime from sqlalchemy import Boolean, DateTime, String @@ -15,7 +16,9 @@ def utc_now() -> datetime: class User(Base): __tablename__ = "users" - id: Mapped[int] = mapped_column(primary_key=True, index=True) + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, index=True, default=uuid.uuid4 + ) email: Mapped[str] = mapped_column( String(255), unique=True, index=True, nullable=False ) diff --git a/app/models/verification_token.py b/app/models/verification_token.py index 53d65d5..3334dd9 100644 --- a/app/models/verification_token.py +++ b/app/models/verification_token.py @@ -16,7 +16,7 @@ class VerificationToken(Base): Attributes: id (int): Primary key identifier for the token. - user_id (int): Foreign key referencing the associated user. + user_id (uuid.UUID): Foreign key referencing the associated user. token (str): Unique token string used for verification. expires_at (datetime): Timestamp indicating when the token expires. created_at (datetime): Timestamp indicating when the token was created. @@ -25,7 +25,7 @@ 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) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) token: Mapped[str] = mapped_column( String(36), unique=True, diff --git a/app/schemas/auth.py b/app/schemas/auth.py index c27391c..2155aa4 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,3 +1,5 @@ +import uuid + from pydantic import BaseModel, EmailStr from app.schemas.user import UserResponse @@ -22,3 +24,23 @@ class VerifyEmailResponse(BaseModel): class ResendVerificationRequest(BaseModel): email: EmailStr + + +class LoginRequest(BaseModel): + """Credentials submitted to ``POST /auth/login``.""" + + email: EmailStr + password: str + + +class LoginResponse(BaseModel): + """Payload returned on successful login. + + The refresh token is delivered exclusively via an HttpOnly cookie - + it is intentionally *not* included in the response body. + """ + + access_token: str + user_id: uuid.UUID + token_type: str = "bearer" + expires_in: int diff --git a/app/schemas/user.py b/app/schemas/user.py index c49a8d1..e024069 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime from enum import StrEnum @@ -53,7 +54,7 @@ def strip_full_name(cls, value: str | None) -> str | None: class UserResponse(UserBase): - id: int + id: uuid.UUID is_active: bool is_verified: bool created_at: datetime diff --git a/app/services/account_lockout.py b/app/services/account_lockout.py new file mode 100644 index 0000000..ed88ebf --- /dev/null +++ b/app/services/account_lockout.py @@ -0,0 +1,102 @@ +"""Redis-backed account-lockout service. + +Tracks consecutive failed login attempts per email address and +locks the account for a configurable period once the threshold is +reached. + +Redis keys +---------- +``login_attempts:{email}`` - integer counter, no TTL (cleared on success). +``account_locked:{email}`` - flag (value ``"1"``), TTL = lockout period. +""" + +import logging + +import redis.asyncio as aioredis + +from app.core.config import settings +from app.core.sanitize import sanitize_log_args + +logger = logging.getLogger(__name__) + +_REDIS_CLIENT: aioredis.Redis | None = None + + +def _get_redis_client() -> aioredis.Redis: + """Return (and lazily create) a module-level async Redis client.""" + global _REDIS_CLIENT # noqa: PLW0603 + if _REDIS_CLIENT is None: + _REDIS_CLIENT = aioredis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + decode_responses=True, + ) + return _REDIS_CLIENT + + +class AccountLockoutService: + """Enforces an account-lockout policy based on consecutive failures. + + After ``MAX_FAILED_LOGIN_ATTEMPTS`` wrong-password attempts on the + same email address, the account is locked for + ``ACCOUNT_LOCKOUT_DAYS`` days. A successful login resets the + failure counter. + """ + + ATTEMPTS_PREFIX = "login_attempts" + LOCKED_PREFIX = "account_locked" + + def __init__(self, redis_client: aioredis.Redis | None = None) -> None: + self._redis = redis_client or _get_redis_client() + self._max_attempts = settings.MAX_FAILED_LOGIN_ATTEMPTS + self._lockout_ttl = settings.ACCOUNT_LOCKOUT_DAYS * 86400 + + # ------------------------------------------------------------------ + # Key helpers + # ------------------------------------------------------------------ + + def _attempts_key(self, email: str) -> str: + return f"{self.ATTEMPTS_PREFIX}:{email}" + + def _locked_key(self, email: str) -> str: + return f"{self.LOCKED_PREFIX}:{email}" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def is_locked(self, email: str) -> bool: + """Return ``True`` if the account for *email* is currently locked.""" + return bool(await self._redis.exists(self._locked_key(email))) + + async def record_failed_attempt(self, email: str) -> None: + """Increment the failure counter and lock the account if threshold reached.""" + attempts_key = self._attempts_key(email) + count = await self._redis.incr(attempts_key) + + if count >= self._max_attempts: + # Lock the account and clear the counter. + await self._redis.set( + self._locked_key(email), + "1", + ex=self._lockout_ttl, + ) + await self._redis.delete(attempts_key) + logger.warning( + "Account locked for %s after %d failed attempts", + sanitize_log_args(email), + count, + ) + + async def reset_attempts(self, email: str) -> None: + """Clear the failure counter (called on successful login).""" + await self._redis.delete(self._attempts_key(email)) + + +# Module-level singleton ----------------------------------------------- +account_lockout_service = AccountLockoutService() + + +def get_account_lockout_service() -> AccountLockoutService: + """FastAPI dependency returning the module-level AccountLockoutService.""" + return account_lockout_service diff --git a/app/services/auth_verification.py b/app/services/auth_verification.py index 1094fa0..70c5106 100644 --- a/app/services/auth_verification.py +++ b/app/services/auth_verification.py @@ -1,3 +1,4 @@ +import uuid from datetime import UTC, datetime from typing import Final from uuid import UUID @@ -29,7 +30,9 @@ def __init__( ) -> None: self._token_repository: Final[VerificationTokenRepository] = token_repository - def create_verification_token(self, db: Session, user_id: int) -> VerificationToken: + def create_verification_token( + self, db: Session, user_id: uuid.UUID + ) -> VerificationToken: return self._token_repository.create_token(db=db, user_id=user_id) def verify_email(self, db: Session, token: str | None) -> None: diff --git a/app/services/token_store.py b/app/services/token_store.py new file mode 100644 index 0000000..54ffc69 --- /dev/null +++ b/app/services/token_store.py @@ -0,0 +1,65 @@ +"""Redis-backed refresh-token persistence. + +Stores refresh-token JTIs in Redis so they can be validated during +token rotation and revoked on logout. +""" + +import logging + +import redis.asyncio as aioredis + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_REDIS_CLIENT: aioredis.Redis | None = None + + +def _get_redis_client() -> aioredis.Redis: + """Return (and lazily create) a module-level async Redis client.""" + global _REDIS_CLIENT # noqa: PLW0603 + if _REDIS_CLIENT is None: + _REDIS_CLIENT = aioredis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + decode_responses=True, + ) + return _REDIS_CLIENT + + +class TokenStoreService: + """Manages refresh-token JTIs in Redis. + + Each stored key has the shape ``refresh_token:{jti}`` with a TTL + that mirrors the token's own expiry, so stale entries are cleaned + up automatically. + """ + + PREFIX = "refresh_token" + + def __init__(self, redis_client: aioredis.Redis | None = None) -> None: + self._redis = redis_client or _get_redis_client() + + def _key(self, jti: str) -> str: + return f"{self.PREFIX}:{jti}" + + async def save_refresh_token(self, jti: str, ttl_seconds: int) -> None: + """Persist *jti* with an automatic expiry of *ttl_seconds*.""" + await self._redis.set(self._key(jti), "1", ex=ttl_seconds) + + async def revoke_refresh_token(self, jti: str) -> None: + """Remove *jti*, effectively revoking the refresh token.""" + await self._redis.delete(self._key(jti)) + + async def is_refresh_token_valid(self, jti: str) -> bool: + """Return ``True`` if *jti* exists (has not been revoked/expired).""" + return bool(await self._redis.exists(self._key(jti))) + + +# Module-level singleton ----------------------------------------------- +token_store_service = TokenStoreService() + + +def get_token_store_service() -> TokenStoreService: + """FastAPI dependency that returns the module-level TokenStoreService.""" + return token_store_service diff --git a/fail_log.txt b/fail_log.txt new file mode 100644 index 0000000..efca2c8 --- /dev/null +++ b/fail_log.txt @@ -0,0 +1,42 @@ +============================= test session starts ============================= +platform win32 -- Python 3.13.12, pytest-9.0.2, pluggy-1.6.0 +rootdir: C:\Users\afiaa\Desktop\projects\Brints\FluentMeet +configfile: pyproject.toml +plugins: anyio-4.12.1, asyncio-1.3.0, cov-7.0.0 +asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function +collected 13 items + +tests\test_auth\test_schemas_user.py F [ 7%] +tests\test_auth\test_auth_login.py F........... [100%] + +================================== FAILURES =================================== +_______________ test_user_response_can_validate_from_attributes _______________ +tests\test_auth\test_schemas_user.py:19: in test_user_response_can_validate_from_attributes + result = UserResponse.model_validate(source) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E pydantic_core._pydantic_core.ValidationError: 1 validation error for UserResponse +E id +E UUID input should be a string, bytes or UUID object [type=uuid_type, input_value=123, input_type=int] +E For further information visit https://errors.pydantic.dev/2.12/v/uuid_type +___________ TestLoginSuccess.test_returns_access_token_and_user_id ____________ +tests\test_auth\test_auth_login.py:188: in test_returns_access_token_and_user_id + assert body["user_id"] == user.id +E AssertionError: assert 'fe03281c-c879-493c-a135-f0fb8603c156' == UUID('fe03281c-c879-493c-a135-f0fb8603c156') +E + where UUID('fe03281c-c879-493c-a135-f0fb8603c156') = .id +------------------------------ Captured log call ------------------------------ +WARNING passlib.handlers.bcrypt:bcrypt.py:642 (trapped) error reading bcrypt version +Traceback (most recent call last): + File "C:\Users\afiaa\Desktop\projects\Brints\FluentMeet\.venv\Lib\site-packages\passlib\handlers\bcrypt.py", line 640, in _load_backend_mixin + version = _bcrypt.__about__.__version__ + ^^^^^^^^^^^^^^^^^ +AttributeError: module 'bcrypt' has no attribute '__about__' +WARNING passlib.handlers.bcrypt:bcrypt.py:642 (trapped) error reading bcrypt version +Traceback (most recent call last): + File "C:\Users\afiaa\Desktop\projects\Brints\FluentMeet\.venv\Lib\site-packages\passlib\handlers\bcrypt.py", line 640, in _load_backend_mixin + version = _bcrypt.__about__.__version__ + ^^^^^^^^^^^^^^^^^ +AttributeError: module 'bcrypt' has no attribute '__about__' +=========================== short test summary info =========================== +FAILED tests/test_auth/test_schemas_user.py::test_user_response_can_validate_from_attributes +FAILED tests/test_auth/test_auth_login.py::TestLoginSuccess::test_returns_access_token_and_user_id +======================== 2 failed, 11 passed in 45.84s ======================== diff --git a/requirements.txt b/requirements.txt index e1c8688..0f04918 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,6 +63,7 @@ openai==2.26.0 packaging==26.0 passlib==1.7.4 pathspec==1.0.4 +psycopg2-binary==2.9.11 platformdirs==4.9.4 pluggy==1.6.0 propcache==0.4.1 diff --git a/tests/test_auth/test_auth_login.py b/tests/test_auth/test_auth_login.py new file mode 100644 index 0000000..ccbd2b0 --- /dev/null +++ b/tests/test_auth/test_auth_login.py @@ -0,0 +1,402 @@ +"""Integration tests for ``POST /api/v1/auth/login``.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.rate_limiter import limiter +from app.core.security import SecurityService +from app.db.session import get_db +from app.main import app +from app.models.user import Base, User +from app.services.account_lockout import ( + AccountLockoutService, + get_account_lockout_service, +) +from app.services.email_producer import get_email_producer_service +from app.services.token_store import ( + TokenStoreService, + get_token_store_service, +) + +# --------------------------------------------------------------------------- +# Fake Redis for token-store and lockout without a real Redis instance +# --------------------------------------------------------------------------- + + +class FakeRedis: + """In-memory stand-in for ``redis.asyncio.Redis``.""" + + def __init__(self) -> None: + self._store: dict[str, str] = {} + + async def set( + self, + key: str, + value: str, + ex: int | None = None, # noqa: ARG002 + ) -> None: + self._store[key] = value + + async def get(self, key: str) -> str | None: + return self._store.get(key) + + async def delete(self, key: str) -> None: + self._store.pop(key, None) + + async def exists(self, key: str) -> int: + return 1 if key in self._store else 0 + + async def incr(self, key: str) -> int: + current = int(self._store.get(key, "0")) + current += 1 + self._store[key] = str(current) + return current + + def reset(self) -> None: + self._store.clear() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def db_session() -> Generator[Session, None, None]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + ) + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture +def fake_redis() -> FakeRedis: + return FakeRedis() + + +@pytest.fixture +def email_producer_mock() -> AsyncMock: + mock = AsyncMock() + mock.send_email = AsyncMock() + return mock + + +@pytest.fixture +def token_store(fake_redis: FakeRedis) -> TokenStoreService: + return TokenStoreService(redis_client=fake_redis) # type: ignore[arg-type] + + +@pytest.fixture +def lockout_svc(fake_redis: FakeRedis) -> AccountLockoutService: + return AccountLockoutService(redis_client=fake_redis) # type: ignore[arg-type] + + +@pytest.fixture +def client( + db_session: Session, + email_producer_mock: AsyncMock, + token_store: TokenStoreService, + lockout_svc: AccountLockoutService, +) -> Generator[TestClient, None, None]: + def _override_get_db() -> Generator[Session, None, None]: + yield db_session + + def _override_email_producer() -> AsyncMock: + return email_producer_mock + + def _override_token_store() -> TokenStoreService: + return token_store + + def _override_lockout_svc() -> AccountLockoutService: + return lockout_svc + + app.dependency_overrides[get_db] = _override_get_db + app.dependency_overrides[get_email_producer_service] = _override_email_producer + app.dependency_overrides[get_token_store_service] = _override_token_store + app.dependency_overrides[get_account_lockout_service] = _override_lockout_svc + + # Disable slowapi rate limiting so repeated test requests don't hit 429. + limiter.enabled = False + with TestClient(app) as test_client: + yield test_client + limiter.enabled = True + app.dependency_overrides.clear() + + +def _seed_user( + db: Session, + *, + email: str = "user@example.com", + password: str = "MyStr0ngP@ss!", + is_verified: bool = True, + deleted_at: datetime | None = None, +) -> User: + """Insert a user directly into the testing DB.""" + svc = SecurityService() + user = User( + email=email.lower(), + hashed_password=svc.hash_password(password), + full_name="Test User", + is_active=True, + is_verified=is_verified, + deleted_at=deleted_at, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + + +class TestLoginSuccess: + """``POST /auth/login`` — happy path (200).""" + + def test_returns_access_token_and_user_id( + self, client: TestClient, db_session: Session + ) -> None: + user = _seed_user(db_session) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 200 + body = response.json() + assert "access_token" in body + assert body["user_id"] == str(user.id) + assert body["token_type"] == "bearer" + assert body["expires_in"] > 0 + + def test_sets_httponly_refresh_cookie( + self, client: TestClient, db_session: Session + ) -> None: + _seed_user(db_session) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 200 + cookies = response.cookies + assert "refresh_token" in cookies + + def test_refresh_token_not_in_body( + self, client: TestClient, db_session: Session + ) -> None: + _seed_user(db_session) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 200 + body = response.json() + assert "refresh_token" not in body + + def test_stores_refresh_jti_in_token_store( + self, + client: TestClient, + db_session: Session, + fake_redis: FakeRedis, + ) -> None: + _seed_user(db_session) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 200 + # At least one refresh_token:* key should exist + refresh_keys = [k for k in fake_redis._store if k.startswith("refresh_token:")] + assert len(refresh_keys) == 1 + + +class TestLoginInvalidCredentials: + """``POST /auth/login`` — wrong password / non-existent email (401).""" + + def test_wrong_password_returns_401( + self, client: TestClient, db_session: Session + ) -> None: + _seed_user(db_session) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "WrongPassword!"}, + ) + + assert response.status_code == 401 + assert response.json() == { + "status": "error", + "code": "INVALID_CREDENTIALS", + "message": "Invalid email or password.", + "details": [], + } + + def test_nonexistent_email_returns_401(self, client: TestClient) -> None: + response = client.post( + "/api/v1/auth/login", + json={"email": "nobody@example.com", "password": "Whatever123!"}, + ) + + assert response.status_code == 401 + assert response.json()["code"] == "INVALID_CREDENTIALS" + + def test_same_error_for_wrong_password_and_missing_email( + self, client: TestClient, db_session: Session + ) -> None: + """No user-enumeration leakage.""" + _seed_user(db_session) + + wrong_pw = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "WrongPassword!"}, + ) + missing = client.post( + "/api/v1/auth/login", + json={"email": "ghost@example.com", "password": "Whatever123!"}, + ) + + assert wrong_pw.json() == missing.json() + + +class TestLoginUnverifiedAccount: + """``POST /auth/login`` — unverified email (403).""" + + def test_unverified_user_returns_403( + self, client: TestClient, db_session: Session + ) -> None: + _seed_user(db_session, is_verified=False) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 403 + assert response.json() == { + "status": "error", + "code": "EMAIL_NOT_VERIFIED", + "message": "Please verify your email before logging in.", + "details": [], + } + + +class TestLoginDeletedAccount: + """``POST /auth/login`` — soft-deleted user (403).""" + + def test_deleted_user_returns_403( + self, client: TestClient, db_session: Session + ) -> None: + _seed_user(db_session, deleted_at=datetime.now(UTC)) + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 403 + assert response.json() == { + "status": "error", + "code": "ACCOUNT_DELETED", + "message": "This account has been deleted.", + "details": [], + } + + +class TestLoginAccountLockout: + """``POST /auth/login`` — lockout after 5 failures (403).""" + + def test_locked_account_returns_403( + self, + client: TestClient, + db_session: Session, + fake_redis: FakeRedis, + ) -> None: + _seed_user(db_session) + + # Simulate 5 failed attempts by writing the lock key directly + fake_redis._store["account_locked:user@example.com"] = "1" + + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 403 + assert response.json()["code"] == "ACCOUNT_LOCKED" + + def test_five_failures_triggers_lockout( + self, + client: TestClient, + db_session: Session, + ) -> None: + _seed_user(db_session) + + for _ in range(5): + client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "WrongPassword!"}, + ) + + # The next attempt (even with the correct password) should be locked + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + + assert response.status_code == 403 + assert response.json()["code"] == "ACCOUNT_LOCKED" + + def test_successful_login_resets_counter( + self, + client: TestClient, + db_session: Session, + fake_redis: FakeRedis, + ) -> None: + _seed_user(db_session) + + # 4 failures (just under threshold) + for _ in range(4): + client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "WrongPassword!"}, + ) + + # Successful login resets the counter + response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}, + ) + assert response.status_code == 200 + + # Counter should be cleared + assert "login_attempts:user@example.com" not in fake_redis._store diff --git a/tests/test_auth/test_schemas_user.py b/tests/test_auth/test_schemas_user.py index f3f2705..b2c25c5 100644 --- a/tests/test_auth/test_schemas_user.py +++ b/tests/test_auth/test_schemas_user.py @@ -1,3 +1,4 @@ +import uuid from datetime import UTC, datetime from types import SimpleNamespace @@ -5,8 +6,9 @@ def test_user_response_can_validate_from_attributes() -> None: + mock_id = uuid.uuid4() source = SimpleNamespace( - id=123, + id=mock_id, email="person@example.com", full_name="Test Person", speaking_language="en", @@ -18,7 +20,7 @@ def test_user_response_can_validate_from_attributes() -> None: result = UserResponse.model_validate(source) - assert result.id == 123 + assert result.id == mock_id assert result.email == "person@example.com" assert result.speaking_language == SupportedLanguage.ENGLISH assert result.listening_language == SupportedLanguage.FRENCH diff --git a/tests/test_core/test_security.py b/tests/test_core/test_security.py new file mode 100644 index 0000000..e4d028c --- /dev/null +++ b/tests/test_core/test_security.py @@ -0,0 +1,100 @@ +"""Unit tests for ``app.core.security.SecurityService``.""" + +from jose import jwt + +from app.core.config import settings +from app.core.security import SecurityService + + +class TestVerifyPassword: + """Test password hashing and verification.""" + + def setup_method(self) -> None: + self.svc = SecurityService() + + def test_correct_password_returns_true(self) -> None: + hashed = self.svc.hash_password("MyStr0ngP@ss!") + assert self.svc.verify_password("MyStr0ngP@ss!", hashed) is True + + def test_wrong_password_returns_false(self) -> None: + hashed = self.svc.hash_password("MyStr0ngP@ss!") + assert self.svc.verify_password("WrongPassword!", hashed) is False + + def test_hash_is_not_plaintext(self) -> None: + hashed = self.svc.hash_password("MyStr0ngP@ss!") + assert hashed != "MyStr0ngP@ss!" + assert hashed.startswith("$2") + + +class TestCreateAccessToken: + """Test JWT access-token generation.""" + + def setup_method(self) -> None: + self.svc = SecurityService() + + def test_returns_decodable_jwt_with_correct_claims(self) -> None: + token, _expires_in = self.svc.create_access_token( + email="user@example.com", + jti="test-jti-123", + ) + + decoded = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + assert decoded["sub"] == "user@example.com" + assert decoded["jti"] == "test-jti-123" + assert decoded["type"] == "access" + assert "exp" in decoded + + def test_expires_in_matches_config(self) -> None: + _token, expires_in = self.svc.create_access_token(email="user@example.com") + assert expires_in == settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + + def test_auto_generates_jti_when_omitted(self) -> None: + token, _ = self.svc.create_access_token(email="user@example.com") + decoded = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + assert decoded["jti"] # non-empty + + +class TestCreateRefreshToken: + """Test JWT refresh-token generation.""" + + def setup_method(self) -> None: + self.svc = SecurityService() + + def test_returns_decodable_jwt_with_correct_claims(self) -> None: + token, jti, _ttl = self.svc.create_refresh_token( + email="user@example.com", + jti="refresh-jti-456", + ) + + decoded = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + assert decoded["sub"] == "user@example.com" + assert decoded["jti"] == "refresh-jti-456" + assert decoded["type"] == "refresh" + assert "exp" in decoded + assert jti == "refresh-jti-456" + + def test_ttl_matches_config(self) -> None: + _token, _jti, ttl = self.svc.create_refresh_token(email="user@example.com") + assert ttl == settings.REFRESH_TOKEN_EXPIRE_DAYS * 86400 + + def test_auto_generates_jti_when_omitted(self) -> None: + token, jti, _ = self.svc.create_refresh_token(email="user@example.com") + assert jti # non-empty + decoded = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + assert decoded["jti"] == jti