login passwd

This commit is contained in:
2026-04-25 19:54:39 +02:00
parent 8cd28873e8
commit 4e26248dc8
10 changed files with 277 additions and 6 deletions

126
backend/app/auth.py Normal file
View File

@@ -0,0 +1,126 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from fastapi import Cookie, Depends, HTTPException, Request, status
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from . import models
from .database import get_db
AUTH_COOKIE_NAME = "professeur_top_session"
AUTH_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "720"))
AUTH_SECRET_KEY = os.getenv("AUTH_SECRET_KEY") or os.getenv("SECRET_KEY") or "dev-secret-change-me"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_access_token(user: models.User) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload: dict[str, Any] = {
"sub": str(user.id),
"username": user.username,
"role": user.role,
"student_id": user.student_id,
"exp": expires_at,
}
return jwt.encode(payload, AUTH_SECRET_KEY, algorithm=AUTH_ALGORITHM)
def decode_access_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, AUTH_SECRET_KEY, algorithms=[AUTH_ALGORITHM])
except jwt.PyJWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session invalide ou expiree",
) from exc
def get_token_from_request(
request: Request,
session_cookie: str | None = Cookie(default=None, alias=AUTH_COOKIE_NAME),
) -> str | None:
if session_cookie:
return session_cookie
authorization = request.headers.get("Authorization", "")
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value:
return value
return None
def get_current_user(
request: Request,
session_cookie: str | None = Cookie(default=None, alias=AUTH_COOKIE_NAME),
db: Session = Depends(get_db),
) -> models.User:
token = get_token_from_request(request, session_cookie)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
)
payload = decode_access_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session invalide",
)
user = db.query(models.User).filter_by(id=int(user_id)).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur introuvable",
)
return user
def require_roles(*roles: str):
def dependency(current_user: models.User = Depends(get_current_user)) -> models.User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Droits insuffisants",
)
return current_user
return dependency
def seed_admin_user(db: Session) -> None:
username = os.getenv("ADMIN_USERNAME")
password = os.getenv("ADMIN_PASSWORD")
if not username or not password:
return
existing = db.query(models.User).filter_by(username=username).first()
if existing:
if existing.role != "teacher":
existing.role = "teacher"
db.commit()
return
db.add(
models.User(
username=username,
password_hash=hash_password(password),
role="teacher",
)
)
db.commit()

View File

@@ -1,6 +1,9 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./local.db")

View File

@@ -1,9 +1,17 @@
from contextlib import asynccontextmanager
import os
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from .database import Base, engine, get_db
from . import models, schemas
from .auth import (
AUTH_COOKIE_NAME,
create_access_token,
get_current_user,
seed_admin_user,
verify_password,
)
from .curriculum import QUESTIONS
from .services import (
build_llm_reply,
@@ -23,6 +31,7 @@ async def lifespan(app: FastAPI):
db = next(get_db())
try:
seed_skills(db)
seed_admin_user(db)
finally:
db.close()
yield
@@ -47,6 +56,36 @@ def health():
return {"status": "ok"}
@app.post("/auth/login", response_model=schemas.LoginResponse)
def login(payload: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(models.User).filter_by(username=payload.username).first()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=401, detail="Identifiants invalides")
token = create_access_token(user)
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
httponly=True,
secure=os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true",
samesite=os.getenv("AUTH_COOKIE_SAMESITE", "lax"),
max_age=int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "720")) * 60,
path="/",
)
return schemas.LoginResponse(access_token=token, user=user)
@app.get("/auth/me", response_model=schemas.UserRead)
def auth_me(current_user: models.User = Depends(get_current_user)):
return current_user
@app.post("/auth/logout")
def logout(response: Response):
response.delete_cookie(key=AUTH_COOKIE_NAME, path="/")
return {"status": "ok"}
@app.get("/students", response_model=list[schemas.StudentRead])
def list_students(db: Session = Depends(get_db)):
return db.query(models.Student).order_by(models.Student.id.asc()).all()

View File

@@ -17,6 +17,19 @@ class Student(Base):
mastery = relationship("StudentSkillMastery", back_populates="student", cascade="all, delete-orphan")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(30), index=True)
student_id: Mapped[int | None] = mapped_column(ForeignKey("students.id"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
student = relationship("Student")
class Message(Base):
__tablename__ = "messages"

View File

@@ -18,6 +18,27 @@ class StudentRead(BaseModel):
from_attributes = True
class UserRead(BaseModel):
id: int
username: str
role: str
student_id: int | None = None
class Config:
from_attributes = True
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserRead
class ChatRequest(BaseModel):
student_id: int
message: str

View File

@@ -8,3 +8,6 @@ python-multipart==0.0.20
openai==1.72.0
redis==5.2.1
alembic==1.15.2
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
PyJWT==2.10.1