hoàn thành phần Authentication

This commit is contained in:
2026-03-31 14:15:32 +07:00
commit ebfa869a26
12 changed files with 389 additions and 0 deletions
View File
+23
View File
@@ -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()
+36
View File
@@ -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
+27
View File
@@ -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")
+66
View File
@@ -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")
View File
+84
View File
@@ -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
+47
View File
@@ -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 364 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
+43
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
from app.main import app
+8
View File
@@ -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
+54
View File
@@ -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.