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