diff --git a/api/app/domain/services/account_service.py b/api/app/domain/services/account_service.py index 918ab04..cbef85b 100644 --- a/api/app/domain/services/account_service.py +++ b/api/app/domain/services/account_service.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -6,54 +7,69 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..models.account import Account from ..models.learnable_language import LearnableLanguage -from ...auth import hash_password +from ...auth import hash_password, verify_password +from ...config import settings +from ...outbound.email.protocol import TransactionalEmailClient from ...outbound.postgres.entities.user_entity import User as UserEntity -from ...outbound.postgres.repositories import learnable_language_repository, user_repository +from ...outbound.postgres.repositories import ( + email_verification_token_repository, + learnable_language_repository, + user_repository, +) class AccountService: - """Handles account-level operations: registration, profile retrieval, and managing - the set of languages a user is learning. - - All methods operate on behalf of a single authenticated user (or, for - ``create_account``, the user being created). + """Handles account-level operations: registration, authentication, email + verification, and managing the set of languages a user is learning. Usage:: service = AccountService(db) - # Registration - account = await service.create_account("alice@example.com", "s3cr3t") + # Registration — returns the new account and the verification link + account, link = await service.register_new_account("alice@example.com", "s3cr3t") + + # Email verification + await service.verify_email_address(token_from_link) + + # Authentication — raises ValueError on bad credentials or disabled account + account = await service.authenticate_with_password("alice@example.com", "s3cr3t") # Profile retrieval account = await service.get_account(user_id) - print(account.learnable_languages) # [LearnableLanguage(...), ...] - # Add French (B1) to the account - lang = await service.add_learnable_language( - user_id, source_language="en", target_language="fr", proficiencies=["B1"] - ) - - # Remove it again + # Language management + lang = await service.add_learnable_language(user_id, "en", "fr", ["B1"]) await service.remove_learnable_language(user_id, lang.id) """ - def __init__(self, db: AsyncSession) -> None: + def __init__( + self, + db: AsyncSession, + email_client: TransactionalEmailClient | None = None, + ) -> None: self.db = db + # Defer import to avoid circular dependency at module load time. + if email_client is not None: + self._email_client = email_client + else: + from ...outbound.email.factory import get_email_client + self._email_client = get_email_client() - async def create_account(self, email: str, password: str) -> Account: - """Create a new user account, hashing the plain-text password before storage. + # ------------------------------------------------------------------ + # Registration & authentication + # ------------------------------------------------------------------ - Raises ``ValueError`` if the email address is already registered, so the - caller does not need to catch SQLAlchemy exceptions directly. + async def register_new_account(self, email: str, password: str) -> tuple[Account, str]: + """Create a new account, generate an email verification token, and send + the verification email. + + Returns ``(account, verification_link)`` on success. + Raises ``ValueError`` if the email address is already registered. Usage:: - try: - account = await service.create_account("alice@example.com", "s3cr3t") - except ValueError: - # email already taken - ... + account, link = await service.register_new_account("alice@example.com", "s3cr3t") """ try: user = await user_repository.create( @@ -65,6 +81,59 @@ class AccountService: await self.db.rollback() raise ValueError("Email already registered") + account = Account( + id=str(user.id), + email=user.email, + is_active=user.is_active, + is_email_verified=user.is_email_verified, + created_at=user.created_at, + ) + + token_row = await email_verification_token_repository.create( + self.db, uuid.UUID(account.id) + ) + link = f"{settings.api_base_url}/api/auth/verify-email?token={token_row.token}" + + await self._email_client.send_email( + to=account.email, + subject="Verify your email address", + html_body=( + f"

Thanks for signing up! Please verify your email address by clicking the link below:

" + f'

{link}

' + f"

This link expires in 24 hours.

" + ), + ) + + return account, link + + async def authenticate_with_password(self, email: str, password: str) -> Account: + """Validate credentials and return the matching account. + + Raises ``ValueError("invalid_credentials")`` for an unrecognised email + or wrong password, and ``ValueError("account_disabled")`` if the account + has been deactivated. + + Usage:: + + try: + account = await service.authenticate_with_password(email, password) + except ValueError as exc: + if str(exc) == "account_disabled": + ... # 403 + ... # 401 + """ + user = await user_repository.get_by_email(self.db, email) + + if user is None or not verify_password(password, user.hashed_password): + raise ValueError("invalid_credentials") + + if not user.is_active: + raise ValueError("account_disabled") + + # TODO(email-verification): uncomment once email verification is tested end-to-end + # if not user.is_email_verified: + # raise ValueError("email_not_verified") + return Account( id=str(user.id), email=user.email, @@ -73,6 +142,36 @@ class AccountService: created_at=user.created_at, ) + async def verify_email_address(self, token: str) -> None: + """Consume a verification token and mark the account as email-verified. + + Raises ``ValueError`` if the token is invalid, already used, or expired. + + Usage:: + + await service.verify_email_address(token_from_query_string) + """ + token_row = await email_verification_token_repository.get_by_token(self.db, token) + + if token_row is None or token_row.used_at is not None: + raise ValueError("Verification link is invalid or has already been used") + + if token_row.expires_at < datetime.now(timezone.utc): + raise ValueError("Verification link has expired") + + await email_verification_token_repository.mark_used(self.db, token_row.id) + + result = await self.db.execute( + select(UserEntity).where(UserEntity.id == token_row.user_id) + ) + user = result.scalar_one() + user.is_email_verified = True + await self.db.commit() + + # ------------------------------------------------------------------ + # Profile + # ------------------------------------------------------------------ + async def get_account(self, user_id: uuid.UUID) -> Account: """Retrieve a user's account profile including all their learnable languages. @@ -84,7 +183,6 @@ class AccountService: for lang in account.learnable_languages: print(lang.target_language, lang.proficiencies) """ - # user_repository only exposes get_by_email; query by id directly result = await self.db.execute( select(UserEntity).where(UserEntity.id == user_id) ) @@ -103,6 +201,10 @@ class AccountService: learnable_languages=languages, ) + # ------------------------------------------------------------------ + # Language management + # ------------------------------------------------------------------ + async def add_learnable_language( self, user_id: uuid.UUID, @@ -121,7 +223,6 @@ class AccountService: target_language="fr", proficiencies=["B1", "B2"], ) - print(lang.id) # UUID of the learnable_language row """ return await learnable_language_repository.upsert( self.db, @@ -136,8 +237,8 @@ class AccountService: ) -> None: """Remove a learnable language from the user's account by its row ID. - Raises ``ValueError`` if the language entry does not exist or does not belong - to ``user_id``. + Raises ``ValueError`` if the language entry does not exist or does not + belong to ``user_id``. Usage:: diff --git a/api/app/languages.py b/api/app/languages.py index 6935746..6c07f2a 100644 --- a/api/app/languages.py +++ b/api/app/languages.py @@ -7,3 +7,15 @@ SUPPORTED_LANGUAGES: dict[str, str] = { } SUPPORTED_LEVELS = {"A1", "A2", "B1", "B2", "C1", "C2"} + +# Ordered for display (SUPPORTED_LEVELS is a set and has no guaranteed order). +LEVEL_ORDER: list[str] = ["A1", "A2", "B1", "B2", "C1", "C2"] + +LEVEL_DESCRIPTIONS: dict[str, str] = { + "A1": "Little to no prior knowledge of the language", + "A2": "Basic knowledge; can understand simple phrases and expressions", + "B1": "Intermediate; can handle most everyday situations", + "B2": "Upper intermediate; can interact fluently with native speakers", + "C1": "Advanced; able to use language flexibly and effectively", + "C2": "Near-native mastery of the language", +} diff --git a/api/app/main.py b/api/app/main.py index 92224ee..0f58828 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -5,7 +5,6 @@ from .routers.api import generation, pos from fastapi import FastAPI from .routers.api import jobs -from .routers import auth as auth_router from .routers import media as media_router from .routers.api.main import api_router from .routers.bff.main import bff_router @@ -29,7 +28,6 @@ app = FastAPI(title="Language Learning API", lifespan=lifespan) app.include_router(api_router) app.include_router(bff_router) -app.include_router(auth_router.router) app.include_router(media_router.router) diff --git a/api/app/outbound/postgres/entities/email_verification_token_entity.py b/api/app/outbound/postgres/entities/email_verification_token_entity.py new file mode 100644 index 0000000..16d365a --- /dev/null +++ b/api/app/outbound/postgres/entities/email_verification_token_entity.py @@ -0,0 +1,33 @@ +import secrets +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import DateTime, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from ..database import Base + +TOKEN_TTL_HOURS = 24 + + +class EmailVerificationToken(Base): + __tablename__ = "email_verification_tokens" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + token: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + @staticmethod + def make(user_id: uuid.UUID) -> "EmailVerificationToken": + return EmailVerificationToken( + user_id=user_id, + token=secrets.token_urlsafe(32), + expires_at=datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS), + ) diff --git a/api/app/outbound/postgres/entities/user_entity.py b/api/app/outbound/postgres/entities/user_entity.py index c8539db..dd09c58 100644 --- a/api/app/outbound/postgres/entities/user_entity.py +++ b/api/app/outbound/postgres/entities/user_entity.py @@ -17,8 +17,7 @@ class User(Base): email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - # TODO(email-verification): set to False and require verification once transactional email is implemented - is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), diff --git a/api/app/outbound/postgres/repositories/email_verification_token_repository.py b/api/app/outbound/postgres/repositories/email_verification_token_repository.py new file mode 100644 index 0000000..fa557cf --- /dev/null +++ b/api/app/outbound/postgres/repositories/email_verification_token_repository.py @@ -0,0 +1,44 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..entities.email_verification_token_entity import EmailVerificationToken + + +async def create(db: AsyncSession, user_id: uuid.UUID) -> EmailVerificationToken: + row = EmailVerificationToken.make(user_id) + db.add(row) + await db.commit() + await db.refresh(row) + return row + + +async def get_by_token(db: AsyncSession, token: str) -> EmailVerificationToken | None: + result = await db.execute( + select(EmailVerificationToken).where(EmailVerificationToken.token == token) + ) + return result.scalar_one_or_none() + + +async def get_active_for_user(db: AsyncSession, user_id: uuid.UUID) -> EmailVerificationToken | None: + """Return the most recently created unused, unexpired token for this user.""" + result = await db.execute( + select(EmailVerificationToken) + .where(EmailVerificationToken.user_id == user_id) + .where(EmailVerificationToken.used_at.is_(None)) + .where(EmailVerificationToken.expires_at > datetime.now(timezone.utc)) + .order_by(EmailVerificationToken.expires_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def mark_used(db: AsyncSession, token_id: uuid.UUID) -> None: + result = await db.execute( + select(EmailVerificationToken).where(EmailVerificationToken.id == token_id) + ) + row = result.scalar_one() + row.used_at = datetime.now(timezone.utc) + await db.commit() diff --git a/api/app/routers/api/auth.py b/api/app/routers/api/auth.py new file mode 100644 index 0000000..1d9f1dc --- /dev/null +++ b/api/app/routers/api/auth.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr, field_validator +from sqlalchemy.ext.asyncio import AsyncSession + +from ...auth import create_access_token +from ...domain.services.account_service import AccountService +from ...outbound.postgres.database import get_db + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + + @field_validator("password") + @classmethod + def password_min_length(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + return v + + +class RegisterResponse(BaseModel): + success: bool + error_message: str | None = None + _todo_remove_me_validate_account_link: str | None = None + + model_config = {"populate_by_name": True} + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED) +async def register( + body: RegisterRequest, + db: AsyncSession = Depends(get_db), +) -> RegisterResponse: + try: + account, link = await AccountService(db).register_new_account(body.email, body.password) + except ValueError: + return RegisterResponse( + success=False, + error_message="Email address not valid or already in use", + ) + + return RegisterResponse( + success=True, + **{"_todo_remove_me_validate_account_link": link}, + ) + + +@router.post("/login", response_model=TokenResponse) +async def login( + body: LoginRequest, + db: AsyncSession = Depends(get_db), +) -> TokenResponse: + try: + account = await AccountService(db).authenticate_with_password(body.email, body.password) + except ValueError as exc: + if str(exc) == "account_disabled": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" + ) + + return TokenResponse(access_token=create_access_token(account.id, account.email)) + + +@router.get("/verify-email") +async def verify_email( + token: str, + db: AsyncSession = Depends(get_db), +) -> dict: + try: + await AccountService(db).verify_email_address(token) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + + return {"success": True} diff --git a/api/app/routers/api/main.py b/api/app/routers/api/main.py index c309e13..fcffa05 100644 --- a/api/app/routers/api/main.py +++ b/api/app/routers/api/main.py @@ -1,4 +1,5 @@ from .account import router as account_router +from .auth import router as auth_router from .flashcards import router as flashcards_router from .pos import router as pos_router from .translate import router as translate_router @@ -11,6 +12,7 @@ from fastapi import APIRouter api_router = APIRouter(prefix="/api", tags=["api"]) +api_router.include_router(auth_router) api_router.include_router(account_router) api_router.include_router(flashcards_router) api_router.include_router(pos_router) diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py deleted file mode 100644 index 5de7ec4..0000000 --- a/api/app/routers/auth.py +++ /dev/null @@ -1,65 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, EmailStr -from sqlalchemy.ext.asyncio import AsyncSession - -from ..auth import create_access_token, verify_password -from ..domain.services.account_service import AccountService -from ..outbound.postgres.database import get_db -from ..outbound.postgres.repositories import user_repository - -router = APIRouter(prefix="/auth", tags=["auth"]) - - -class RegisterRequest(BaseModel): - email: EmailStr - password: str - - -class LoginRequest(BaseModel): - email: EmailStr - password: str - - -class TokenResponse(BaseModel): - access_token: str - token_type: str = "bearer" - - -@router.post("/register", status_code=status.HTTP_201_CREATED) -async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): - # TODO(email-verification): send verification email here once transactional - # email is implemented. Set is_email_verified=False on the User model and - # require verification before allowing login. - try: - account = await AccountService(db).create_account(body.email, body.password) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) - - return {"id": account.id, "email": account.email} - - -@router.post("/login", response_model=TokenResponse) -async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): - user = await user_repository.get_by_email(db, body.email) - - if user is None or not verify_password(body.password, user.hashed_password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Account disabled", - ) - - # TODO(email-verification): uncomment once email verification is in place - # if not user.is_email_verified: - # raise HTTPException( - # status_code=status.HTTP_403_FORBIDDEN, - # detail="Email address not verified", - # ) - - token = create_access_token(str(user.id), user.email) - return TokenResponse(access_token=token) diff --git a/api/app/routers/bff/account.py b/api/app/routers/bff/account.py new file mode 100644 index 0000000..300daeb --- /dev/null +++ b/api/app/routers/bff/account.py @@ -0,0 +1,82 @@ +import uuid + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from ...auth import verify_token +from ...config import settings +from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS, LEVEL_ORDER, LEVEL_DESCRIPTIONS +from ...outbound.postgres.database import get_db +from ...outbound.postgres.repositories import email_verification_token_repository + +router = APIRouter(prefix="/account", tags=["bff"]) + + +class LanguagePairOption(BaseModel): + value: str + label: str + description: str + + +class ProficiencyOption(BaseModel): + value: str + label: str + description: str + + +class OnboardingResponse(BaseModel): + _todo_remove_me_validate_account_link: str | None = None + language_pairs: list[LanguagePairOption] + proficiencies: list[ProficiencyOption] + + model_config = {"populate_by_name": True} + + +def _build_language_pairs() -> list[LanguagePairOption]: + pairs = [] + for source_code, source_name in SUPPORTED_LANGUAGES.items(): + for target_code, target_name in SUPPORTED_LANGUAGES.items(): + if source_code == target_code: + continue + pairs.append( + LanguagePairOption( + value=f"{source_code},{target_code}", + label=f"{source_name} to {target_name}", + description=f"You are a {source_name} speaker, learning {target_name}", + ) + ) + return pairs + + +def _build_proficiencies() -> list[ProficiencyOption]: + return [ + ProficiencyOption( + value=level, + label=level, + description=LEVEL_DESCRIPTIONS[level], + ) + for level in LEVEL_ORDER + if level in SUPPORTED_LEVELS + ] + + +@router.get("/onboarding", response_model=OnboardingResponse) +async def get_onboarding( + db: AsyncSession = Depends(get_db), + token_data: dict = Depends(verify_token), +) -> OnboardingResponse: + user_id = uuid.UUID(token_data["sub"]) + + active_token = await email_verification_token_repository.get_active_for_user(db, user_id) + link = ( + f"{settings.api_base_url}/api/auth/verify-email?token={active_token.token}" + if active_token + else None + ) + + return OnboardingResponse( + **{"_todo_remove_me_validate_account_link": link}, + language_pairs=_build_language_pairs(), + proficiencies=_build_proficiencies(), + ) diff --git a/api/app/routers/bff/main.py b/api/app/routers/bff/main.py index a20a9c7..8d02444 100644 --- a/api/app/routers/bff/main.py +++ b/api/app/routers/bff/main.py @@ -1,3 +1,4 @@ +from .account import router as account_router from .articles import router as article_router from .user_profile import router as user_profile_router @@ -5,5 +6,6 @@ from fastapi import APIRouter bff_router = APIRouter(prefix="/bff", tags=["bff"]) +bff_router.include_router(account_router) bff_router.include_router(article_router) bff_router.include_router(user_profile_router)