From ae62c09881e6a3f83baf367f73a4a2b341eac5a2 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 21:47:01 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20Added=20User/InviteCode=20models,=20Al?= =?UTF-8?q?embic=20migration=20016,=20auth=20utilit=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/models.py" - "backend/auth.py" - "backend/schemas.py" - "backend/requirements.txt" - "alembic/versions/016_add_users_and_invite_codes.py" GSD-Task: S02/T01 --- .../016_add_users_and_invite_codes.py | 50 ++++++++ backend/auth.py | 116 ++++++++++++++++++ backend/models.py | 53 ++++++++ backend/requirements.txt | 2 + backend/schemas.py | 43 +++++++ 5 files changed, 264 insertions(+) create mode 100644 alembic/versions/016_add_users_and_invite_codes.py create mode 100644 backend/auth.py diff --git a/alembic/versions/016_add_users_and_invite_codes.py b/alembic/versions/016_add_users_and_invite_codes.py new file mode 100644 index 0000000..6c86023 --- /dev/null +++ b/alembic/versions/016_add_users_and_invite_codes.py @@ -0,0 +1,50 @@ +"""Add users and invite_codes tables for creator authentication. + +Revision ID: 016_add_users_and_invite_codes +Revises: 015_add_creator_profile +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "016_add_users_and_invite_codes" +down_revision = "015_add_creator_profile" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create user_role enum type + user_role_enum = sa.Enum("creator", "admin", name="user_role", create_constraint=True) + user_role_enum.create(op.get_bind(), checkfirst=True) + + # Create users table + op.create_table( + "users", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column("email", sa.String(255), nullable=False, unique=True), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("role", user_role_enum, nullable=False, server_default="creator"), + sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="SET NULL"), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + # Create invite_codes table + op.create_table( + "invite_codes", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column("code", sa.String(100), nullable=False, unique=True), + sa.Column("uses_remaining", sa.Integer(), nullable=False, server_default="1"), + sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("invite_codes") + op.drop_table("users") + sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True) diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..9e464c3 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,116 @@ +"""Authentication utilities — password hashing, JWT, FastAPI dependencies.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import get_settings +from database import get_session +from models import User, UserRole + +# ── Password hashing ───────────────────────────────────────────────────────── + +_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + """Hash a plaintext password with bcrypt.""" + return _pwd_ctx.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + """Verify a plaintext password against a bcrypt hash.""" + return _pwd_ctx.verify(plain, hashed) + + +# ── JWT ────────────────────────────────────────────────────────────────────── + +_ALGORITHM = "HS256" +_ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def create_access_token( + user_id: uuid.UUID | str, + role: str, + *, + expires_minutes: int = _ACCESS_TOKEN_EXPIRE_MINUTES, +) -> str: + """Create a signed JWT with user_id and role claims.""" + settings = get_settings() + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "role": role, + "iat": now, + "exp": now + timedelta(minutes=expires_minutes), + } + return jwt.encode(payload, settings.app_secret_key, algorithm=_ALGORITHM) + + +def decode_access_token(token: str) -> dict: + """Decode and validate a JWT. Raises on expiry or malformed tokens.""" + settings = get_settings() + try: + payload = jwt.decode( + token, + settings.app_secret_key, + algorithms=[_ALGORITHM], + options={"require": ["sub", "role", "exp"]}, + ) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + ) + except jwt.InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: {exc}", + ) + return payload + + +# ── FastAPI dependencies ───────────────────────────────────────────────────── + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> User: + """Decode JWT, load User from DB, raise 401 if missing or inactive.""" + payload = decode_access_token(token) + user_id = payload.get("sub") + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + return user + + +def require_role(required_role: UserRole): + """Return a dependency that checks the current user has the given role.""" + + async def _check( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + if current_user.role != required_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires {required_role.value} role", + ) + return current_user + + return _check diff --git a/backend/models.py b/backend/models.py index e4aeb46..9da24a5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -12,6 +12,7 @@ import uuid from datetime import datetime, timezone from sqlalchemy import ( + Boolean, Enum, Float, ForeignKey, @@ -73,6 +74,12 @@ class RelationshipType(str, enum.Enum): general_cross_reference = "general_cross_reference" +class UserRole(str, enum.Enum): + """Roles for authenticated users.""" + creator = "creator" + admin = "admin" + + # ── Helpers ────────────────────────────────────────────────────────────────── def _uuid_pk() -> Mapped[uuid.UUID]: @@ -123,6 +130,52 @@ class Creator(Base): technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator") +class User(Base): + """Authenticated user account for the creator dashboard.""" + __tablename__ = "users" + + id: Mapped[uuid.UUID] = _uuid_pk() + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole, name="user_role", create_constraint=True), + default=UserRole.creator, + server_default="creator", + ) + creator_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("creators.id", ondelete="SET NULL"), nullable=True + ) + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true" + ) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now(), onupdate=_now + ) + + # relationships + creator: Mapped[Creator | None] = sa_relationship() + + +class InviteCode(Base): + """Single-use or limited-use invite codes for registration gating.""" + __tablename__ = "invite_codes" + + id: Mapped[uuid.UUID] = _uuid_pk() + code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + uses_remaining: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + created_by: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + expires_at: Mapped[datetime | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + + class SourceVideo(Base): __tablename__ = "source_videos" diff --git a/backend/requirements.txt b/backend/requirements.txt index 39e28d4..58085b0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,8 @@ qdrant-client>=1.9,<2.0 pyyaml>=6.0,<7.0 psycopg2-binary>=2.9,<3.0 watchdog>=4.0,<5.0 +PyJWT>=2.8,<3.0 +passlib[bcrypt]>=1.7,<2.0 # Test dependencies pytest>=8.0,<10.0 pytest-asyncio>=0.24,<1.0 diff --git a/backend/schemas.py b/backend/schemas.py index 2eb7b21..7a8057e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -502,3 +502,46 @@ class AdminTechniquePageListResponse(BaseModel): total: int = 0 offset: int = 0 limit: int = 50 + + +# ── Auth ───────────────────────────────────────────────────────────────────── + +class RegisterRequest(BaseModel): + """Registration payload — requires a valid invite code.""" + email: str = Field(..., min_length=3, max_length=255) + password: str = Field(..., min_length=8, max_length=128) + display_name: str = Field(..., min_length=1, max_length=255) + invite_code: str = Field(..., min_length=1, max_length=100) + creator_slug: str | None = Field(None, max_length=255) + + +class LoginRequest(BaseModel): + """Login payload.""" + email: str + password: str + + +class TokenResponse(BaseModel): + """JWT token response.""" + access_token: str + token_type: str = "bearer" + + +class UserResponse(BaseModel): + """Public user profile response.""" + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + display_name: str + role: str + creator_id: uuid.UUID | None = None + is_active: bool = True + created_at: datetime + + +class UpdateProfileRequest(BaseModel): + """Self-service profile update.""" + display_name: str | None = Field(None, min_length=1, max_length=255) + current_password: str | None = None + new_password: str | None = Field(None, min_length=8, max_length=128)