Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ COPY app/ ./app
RUN uv sync --frozen
ENV PATH="/app/.venv/bin:$PATH"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000", "--workers", "4"]
124 changes: 124 additions & 0 deletions backend/app/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import HTTPBearer
from sqlalchemy.orm import Session
from typing import Annotated
import uuid
from datetime import timedelta, datetime

from app.auth.middleware import get_db, get_current_user
from app.models.user import (
TokenSchema,
UserCreate,
UserLogin,
UserResponse,
User,
AuthToken,
)
from app.auth.utils import (
verify_password,
get_password_hash,
create_access_token,
create_auth_token,
get_user_by_id,
invalidate_session,
ACCESS_TOKEN_EXPIRE_MINUTES,
)

router = APIRouter()
security = HTTPBearer()


@router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
)
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
# Check if user exists
db_user = get_user_by_id(db, user_data.user_id)
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="User ID already registered"
)

# Create new user
hashed_password = get_password_hash(user_data.password)
db_user = User(
user_id=user_data.user_id,
name=user_data.name,
password=hashed_password,
is_teacher=user_data.is_teacher,
courses=[],
)

db.add(db_user)
db.commit()
db.refresh(db_user)

return db_user


@router.post("/login", response_model=TokenSchema)
async def login(
response: Response, user_data: UserLogin, db: Session = Depends(get_db)
):
# Verify user
user = get_user_by_id(db, user_data.user_id)
if not user or not verify_password(user_data.password, user.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)

# Create access token
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.user_id}, expires_delta=access_token_expires
)

# Create auth token with session
session_id = create_auth_token(db, user.user_id, access_token)

# Set cookie
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax",
)

return {
"access_token": access_token,
"token_type": "bearer",
"session_id": session_id,
}


@router.post("/logout")
async def logout(
response: Response,
current_user: User = Depends(get_current_user),
session_id: str = Depends(lambda request: request.cookies.get("session_id")),
db: Session = Depends(get_db),
):
# Invalidate session and related tokens
if session_id:
invalidate_session(db, session_id)

# Revoke all tokens for this user
db.query(AuthToken).filter(
AuthToken.user_id == current_user.user_id,
AuthToken.is_revoked == False,
AuthToken.expires > datetime.now(),
).update({"is_revoked": True})
db.commit()

# Clear cookie
response.delete_cookie(key="session_id")

return {"message": "Successfully logged out"}


@router.get("/whoami", response_model=UserResponse)
async def get_user_me(current_user: User = Depends(get_current_user)):
return current_user
65 changes: 65 additions & 0 deletions backend/app/auth/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from fastapi import Depends, HTTPException, status, Request, Cookie
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime

from app.db import SessionLocal
from app.auth.utils import SECRET_KEY, ALGORITHM, get_user_by_session, verify_token
from app.models.user import TokenData

security = HTTPBearer()


def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()


async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
session_id: Optional[str] = Cookie(None, alias="session_id"),
db: Session = Depends(get_db),
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

try:
# First check the JWT token
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
token_data = TokenData(user_id=user_id)

# Verify token exists and is not revoked in database
if not verify_token(db, token):
raise credentials_exception
except JWTError:
raise credentials_exception

# Then verify the session
if not session_id:
raise credentials_exception

user = get_user_by_session(db, session_id)
if user is None or user.user_id != token_data.user_id:
raise credentials_exception

return user


async def get_optional_user(request: Request, db: Session = Depends(get_db)):
session_id = request.cookies.get("session_id")
if not session_id:
return None

return get_user_by_session(db, session_id)
105 changes: 105 additions & 0 deletions backend/app/auth/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from datetime import datetime, timedelta
import os
import uuid
from typing import Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.models.user import User, AuthToken
from fastapi import HTTPException, status

# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day


def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password):
return pwd_context.hash(password)


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt


def create_auth_token(db: Session, user_id: str, token: str) -> str:
# Create a session with native UUID
session_id = uuid.uuid4()
expires = datetime.now() + timedelta(days=1)

# Create auth token in database
auth_token = AuthToken(
session_id=session_id, token=token, user_id=user_id, expires=expires
)

db.add(auth_token)
db.commit()
db.refresh(auth_token)

# Return string representation for HTTP use
return str(session_id)


def get_user_by_id(db: Session, user_id: str):
return db.query(User).filter(User.user_id == user_id).first()


def get_user_by_session(db: Session, session_id: str):
try:
# Convert string session_id to UUID for database query
uuid_obj = uuid.UUID(session_id)
auth_token = (
db.query(AuthToken)
.filter(
AuthToken.session_id == uuid_obj,
AuthToken.is_revoked == False,
AuthToken.expires > datetime.now(),
)
.first()
)

if not auth_token:
return None

return get_user_by_id(db, auth_token.user_id)
except ValueError:
# Invalid UUID
return None


def invalidate_session(db: Session, session_id: str):
try:
uuid_obj = uuid.UUID(session_id)
db.query(AuthToken).filter(AuthToken.session_id == uuid_obj).update(
{"is_revoked": True}
)
db.commit()
except ValueError:
# Invalid UUID
pass


def verify_token(db: Session, token: str) -> Optional[AuthToken]:
"""Verify if a token exists and is valid"""
return (
db.query(AuthToken)
.filter(
AuthToken.token == token,
AuthToken.is_revoked == False,
AuthToken.expires > datetime.now(),
)
.first()
)
59 changes: 39 additions & 20 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,60 @@
# app/main.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer

from app.db import Base, engine
from app.models import (
assignment,
bookmarklist,
code_snippet,
comment,
course,
material,
note,
section,
user,
)
from app.models import *

from app.slide.comment import router as comment_router
from app.slide.material import router as material_router
from app.slide.note import router as note_router
from app.slide.code_snippet import router as code_snippet_router
from app.slide.bookmarklist import router as bookmarklist_router
from app.auth import router as auth_router


@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: setup database
Base.metadata.reflect(bind=engine)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
yield
# Shutdown: no cleanup needed


app = FastAPI(
title="PeachIDE API",
description="API for the PeachIDE platform",
version="1.0.0",
lifespan=lifespan,
)

app = FastAPI()
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins in development
allow_credentials=True,
allow_methods=["*"], # Allows all methods
allow_headers=["*"], # Allows all headers
)

# Register routers
app.include_router(comment_router, tags=["comments"], prefix="/api")
app.include_router(material_router, tags=["materials"], prefix="/api")
app.include_router(note_router, tags=["notes"], prefix="/api")
app.include_router(code_snippet_router, tags=["code_snippets"], prefix="/api")
app.include_router(bookmarklist_router, tags=["bookmarklists"], prefix="/api")
app.include_router(
auth_router,
tags=["authentication"],
prefix="/api",
responses={401: {"description": "Unauthorized"}},
)


@app.on_event("startup")
def startup():
Base.metadata.reflect(bind=engine)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)


@app.get("/api")
@app.get("/api", tags=["health"])
async def root():
return {"message": "Hello World"}
4 changes: 3 additions & 1 deletion backend/app/models/code_snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ class CodeSnippet(Base):
__tablename__ = "code_snippets"

snippet_id = Column(String, primary_key=True, index=True)
material_id = Column(String, ForeignKey("materials.material_id"), index=True, nullable=False)
material_id = Column(
String, ForeignKey("materials.material_id"), index=True, nullable=False
)
lang = Column(String, nullable=False)
page = Column(Integer, nullable=False)
content = Column(String, nullable=False)
Expand Down
Loading