chore: Added User/InviteCode models, Alembic migration 016, auth utilit…
- "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
This commit is contained in:
parent
33f68ef4cf
commit
ae62c09881
5 changed files with 264 additions and 0 deletions
50
alembic/versions/016_add_users_and_invite_codes.py
Normal file
50
alembic/versions/016_add_users_and_invite_codes.py
Normal file
|
|
@ -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)
|
||||||
116
backend/auth.py
Normal file
116
backend/auth.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -12,6 +12,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
Enum,
|
Enum,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
|
@ -73,6 +74,12 @@ class RelationshipType(str, enum.Enum):
|
||||||
general_cross_reference = "general_cross_reference"
|
general_cross_reference = "general_cross_reference"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
"""Roles for authenticated users."""
|
||||||
|
creator = "creator"
|
||||||
|
admin = "admin"
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _uuid_pk() -> Mapped[uuid.UUID]:
|
def _uuid_pk() -> Mapped[uuid.UUID]:
|
||||||
|
|
@ -123,6 +130,52 @@ class Creator(Base):
|
||||||
technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator")
|
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):
|
class SourceVideo(Base):
|
||||||
__tablename__ = "source_videos"
|
__tablename__ = "source_videos"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ qdrant-client>=1.9,<2.0
|
||||||
pyyaml>=6.0,<7.0
|
pyyaml>=6.0,<7.0
|
||||||
psycopg2-binary>=2.9,<3.0
|
psycopg2-binary>=2.9,<3.0
|
||||||
watchdog>=4.0,<5.0
|
watchdog>=4.0,<5.0
|
||||||
|
PyJWT>=2.8,<3.0
|
||||||
|
passlib[bcrypt]>=1.7,<2.0
|
||||||
# Test dependencies
|
# Test dependencies
|
||||||
pytest>=8.0,<10.0
|
pytest>=8.0,<10.0
|
||||||
pytest-asyncio>=0.24,<1.0
|
pytest-asyncio>=0.24,<1.0
|
||||||
|
|
|
||||||
|
|
@ -502,3 +502,46 @@ class AdminTechniquePageListResponse(BaseModel):
|
||||||
total: int = 0
|
total: int = 0
|
||||||
offset: int = 0
|
offset: int = 0
|
||||||
limit: int = 50
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue