mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:53:10 +07:00
hoàn thành phần Authentication
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql+psycopg2://lms_user:lms_password@db:5432/lms_db",
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from fastapi import Cookie, Depends, HTTPException, status
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
from .models import User
|
||||||
|
from .security import decode_access_token
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
access_token: str | None = Cookie(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
Reads the JWT from the HttpOnly `access_token` cookie,
|
||||||
|
validates it, and returns the authenticated User ORM object.
|
||||||
|
Raises 401 on any failure.
|
||||||
|
"""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if access_token is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = decode_access_token(access_token)
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .database import Base, engine
|
||||||
|
from .routers import auth
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Create all tables on startup (use Alembic for migrations in production)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="LMS API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000"], # Next.js dev server
|
||||||
|
allow_credentials=True, # required for cookies
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
ForeignKey,
|
||||||
|
DateTime,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
pdfs = relationship("PDF", back_populates="owner", cascade="all, delete-orphan")
|
||||||
|
annotations = relationship("Annotation", back_populates="owner", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class PDF(Base):
|
||||||
|
__tablename__ = "pdfs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
file_path = Column(Text, nullable=False) # path inside Docker volume
|
||||||
|
total_pages = Column(Integer, nullable=True) # populated after upload processing
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
owner = relationship("User", back_populates="pdfs")
|
||||||
|
annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Annotation(Base):
|
||||||
|
__tablename__ = "annotations"
|
||||||
|
__table_args__ = (
|
||||||
|
# One annotation record per (pdf, user, page) — upsert target
|
||||||
|
UniqueConstraint("pdf_id", "user_id", "page_number", name="uq_annotation_pdf_user_page"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
pdf_id = Column(Integer, ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
page_number = Column(Integer, nullable=False) # 1-based
|
||||||
|
canvas_data = Column(JSONB, nullable=False, default=dict) # Fabric.js JSON state
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
pdf = relationship("PDF", back_populates="annotations")
|
||||||
|
owner = relationship("User", back_populates="annotations")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import UserLogin, UserOut, UserRegister
|
||||||
|
from ..security import (
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
|
create_access_token,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
_COOKIE_NAME = "access_token"
|
||||||
|
_COOKIE_MAX_AGE = ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _set_auth_cookie(response: Response, user_id: int) -> None:
|
||||||
|
token = create_access_token(user_id)
|
||||||
|
response.set_cookie(
|
||||||
|
key=_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True, # send only over HTTPS (Nginx handles TLS in prod)
|
||||||
|
samesite="lax",
|
||||||
|
max_age=_COOKIE_MAX_AGE,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /auth/register ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def register(body: UserRegister, response: Response, db: Session = Depends(get_db)):
|
||||||
|
if db.query(User).filter(User.username == body.username).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Username already taken.")
|
||||||
|
if db.query(User).filter(User.email == body.email).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered.")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=body.username,
|
||||||
|
email=body.email,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
_set_auth_cookie(response, user.id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /auth/login ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/login", response_model=UserOut)
|
||||||
|
def login(body: UserLogin, response: Response, db: Session = Depends(get_db)):
|
||||||
|
user: User | None = db.query(User).filter(User.username == body.username).first()
|
||||||
|
|
||||||
|
# Constant-time: always verify even when user is None to prevent timing attacks
|
||||||
|
dummy_hash = "$2b$12$KIXyb/r7MBJ3z32Dc3y8YuNfj7DhpNcaYgU2q8uGiHjJ6YFe6RVxm"
|
||||||
|
password_ok = verify_password(body.password, user.password_hash if user else dummy_hash)
|
||||||
|
|
||||||
|
if not user or not password_ok:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password.")
|
||||||
|
|
||||||
|
_set_auth_cookie(response, user.id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /auth/logout ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def logout(response: Response):
|
||||||
|
response.delete_cookie(key=_COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /auth/me ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def username_valid(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not re.match(r"^[A-Za-z0-9_]{3,64}$", v):
|
||||||
|
raise ValueError(
|
||||||
|
"Username must be 3–64 characters, letters/numbers/underscore only."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_strength(cls, v: str) -> str:
|
||||||
|
if len(v) < 8:
|
||||||
|
raise ValueError("Password must be at least 8 characters.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPayload(BaseModel):
|
||||||
|
sub: int # user id
|
||||||
|
exp: int
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import os
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
# ── Config (override via environment variables) ───────────────────────────────
|
||||||
|
SECRET_KEY: str = os.environ["JWT_SECRET_KEY"] # must be set — no default
|
||||||
|
ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(
|
||||||
|
os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Password hashing ──────────────────────────────────────────────────────────
|
||||||
|
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return _pwd_context.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return _pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWT ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_access_token(user_id: int) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
payload = {"sub": str(user_id), "exp": expire}
|
||||||
|
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> int:
|
||||||
|
"""
|
||||||
|
Decode the JWT and return the user_id (int).
|
||||||
|
Raises JWTError on any validation failure.
|
||||||
|
"""
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
sub = payload.get("sub")
|
||||||
|
if sub is None:
|
||||||
|
raise JWTError("Missing subject claim.")
|
||||||
|
return int(sub)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from app.main import app
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
pydantic[email]==2.9.2
|
||||||
|
python-multipart==0.0.12
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Project Specification: Personal PDF Learning Management System (LMS)
|
||||||
|
|
||||||
|
## 1. Project Overview
|
||||||
|
A web application that allows users to securely log in, upload PDF files to a personal gallery, and interact with those PDFs in a dedicated "Workbook" interface. The core feature is the ability to draw, highlight, and add text directly onto the PDF pages from any device (Desktop & Mobile), saving these annotations for future review.
|
||||||
|
|
||||||
|
## 2. Tech Stack Definition
|
||||||
|
Please use the following technologies for this project:
|
||||||
|
* **Frontend:** React.js (or Next.js) + Tailwind CSS.
|
||||||
|
* **PDF Rendering:** PDF.js (Mozilla) to render PDF pages to canvas/DOM.
|
||||||
|
* **Annotation Layer:** Fabric.js (overlaying the PDF to handle drawing, shapes, and text).
|
||||||
|
* **Backend:** FastAPI (Python) OR Express (Node.js) - *Copilot: please ask me which one to start with.*
|
||||||
|
* **Database:** PostgreSQL (using Prisma or SQLAlchemy for ORM).
|
||||||
|
* **Authentication:** JWT (JSON Web Tokens) with HttpOnly cookies.
|
||||||
|
* **Storage:** Local file system (Docker volume) for storing uploaded PDFs.
|
||||||
|
|
||||||
|
## 3. Core Features & Requirements
|
||||||
|
|
||||||
|
### 3.1. Authentication (JWT)
|
||||||
|
* User Registration and Login.
|
||||||
|
* Protect all API routes.
|
||||||
|
* Each user can only see and interact with their own uploaded PDFs.
|
||||||
|
|
||||||
|
### 3.2. PDF Gallery (Dashboard)
|
||||||
|
* **Upload:** Multi-file drag-and-drop upload.
|
||||||
|
* **Display:** Responsive CSS Grid displaying uploaded PDFs as cards. Include the document title and upload date.
|
||||||
|
* **Action:** Clicking a card opens the PDF in the "Workbook" view.
|
||||||
|
|
||||||
|
### 3.3. Interactive Workbook (The Core Viewer)
|
||||||
|
* **Responsive UI:** Must work seamlessly on mobile and desktop.
|
||||||
|
* **PDF Viewer:** Render the selected PDF file. Support pagination (Next/Prev page).
|
||||||
|
* **Annotation Tools (Fabric.js):**
|
||||||
|
* Pen tool (freehand drawing/circling).
|
||||||
|
* Highlighter tool (semi-transparent freehand or straight line).
|
||||||
|
* Text tool (click to type text).
|
||||||
|
* **Mobile Touch UX (Crucial):** Implement a toggle switch between two modes:
|
||||||
|
1. *Pan Mode:* Touch and swipe will scroll/zoom the page. Canvas drawing is disabled.
|
||||||
|
2. *Draw/Edit Mode:* Scrolling is locked. Touch events are passed to Fabric.js for drawing/annotating.
|
||||||
|
* **Save Mechanism:** A "Save" button that serializes the current Fabric.js canvas state into JSON and POSTs it to the backend.
|
||||||
|
* **Load Mechanism:** When opening a PDF, fetch the saved JSON annotation data and load it onto the Fabric.js canvas over the PDF.
|
||||||
|
|
||||||
|
## 4. Database Schema (Draft)
|
||||||
|
|
||||||
|
* `users`: id, username, password_hash, created_at
|
||||||
|
* `pdfs`: id, user_id (FK), title, file_path, created_at
|
||||||
|
* `annotations`: id, pdf_id (FK), user_id (FK), page_number, canvas_data (JSON), updated_at
|
||||||
|
|
||||||
|
## 5. Deployment strategy
|
||||||
|
* Provide a `docker-compose.yml` to spin up the Frontend, Backend, and PostgreSQL database together.
|
||||||
|
|
||||||
|
## 6. Instructions for Copilot
|
||||||
|
Let's build this step-by-step to avoid context limits.
|
||||||
|
1. First, analyze this spec and confirm you understand.
|
||||||
|
2. Provide the database schema models (SQLAlchemy or Prisma).
|
||||||
|
3. Wait for my confirmation before writing the Backend API routes.
|
||||||
Reference in New Issue
Block a user