diff --git a/.env b/.env index 9ebb048..4cbc0e5 100644 --- a/.env +++ b/.env @@ -1 +1,5 @@ -OPENAI_API_KEY=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 +OPENAI_API_KEY=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 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin +AUTH_SECRET_KEY=dev-secret-change-me +AUTH_COOKIE_SECURE=false diff --git a/.env.example b/.env.example index ed6ed73..372c933 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ OPENAI_API_KEY=sk-... +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +AUTH_SECRET_KEY=replace-with-a-long-random-secret +AUTH_COOKIE_SECURE=false diff --git a/MEMORY.md b/MEMORY.md index 58bdd3f..0e862bd 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -14,6 +14,9 @@ Ce projet est un POC de professeur virtuel pour enfants, avec: - Le frontend appelle l'API via le prefixe `/api`. - Le backend expose une route `POST /transcribe` pour la transcription audio. - La route `/transcribe` utilise `UploadFile`, donc `python-multipart` est requis dans le backend. +- Le backend a un socle d'authentification avec comptes `users`, roles `student`, `teacher`, `maintenance`. +- Le compte admin/prof initial est seede depuis `.env` via `ADMIN_USERNAME` et `ADMIN_PASSWORD`. +- Le login pose un cookie HTTP-only `professeur_top_session` et renvoie aussi un token bearer. - Le vrai `docker-compose.yml` de production n'est pas dans ce repo. Il est situe un niveau au-dessus sur le serveur. - La conf nginx reelle route: - `/api/` vers `tutor-backend:8000` @@ -36,21 +39,68 @@ Ce projet est un POC de professeur virtuel pour enfants, avec: ## Voix du Professeur -- Le frontend charge les voix du navigateur via `speechSynthesis.getVoices()`. -- Une liste de voix est proposee dans l'interface. -- Les voix francaises sont priorisees dans le tri. +- L'ancien systeme de voix navigateur a ete remplace par une vraie TTS OpenAI. +- Le backend expose maintenant: + - `GET /tts/profiles` pour lister les profils de voix + - `POST /tts` pour generer un MP3 a partir d'un texte et d'un `profile_id` +- Les profils actuels sont: + - `rigolote` + - `petillante` + - `douce` + - `sobre` +- Chaque profil TTS definit: + - une voix OpenAI + - une vitesse + - des instructions de style vocal +- Le frontend ne depend plus de `speechSynthesis` pour la voix sortante. +- Le choix de voix est memorise dans `localStorage` avec la cle `professeur-top-tts-profile`. +- La lecture audio se fait via un blob MP3 retourne par le backend. +- Le mode micro auto doit continuer a se re-armer apres la fin de lecture du MP3. + +## Identite Produit + +- Le personnage et le nom visibles dans le frontend ont ete renommes de `ProfAmi` vers `Professeur TOP`. +- Le nouvel avatar image est stocke dans: + - `frontend/src/assets/professeur-top.png` +- Le composant avatar React utilise maintenant l'image raster au lieu du visage CSS/SVG precedent. +- Le backend a aussi ete aligne dans le prompt systeme et le message de debut de session avec le nom `Professeur TOP`. + +## Notes de Session 2026-04-24 21:58:39 +02:00 + +- Repo clone dans `c:\DataS\OpenSquared\OpenSchool\ProfTop`. +- `safe.directory` Git ajoute globalement pour ce dossier a cause d'un mismatch d'ownership Windows. +- Image utilisateur `face.png` integree dans le frontend sous `frontend/src/assets/professeur-top.png`. +- Libelles assistant remplaces par `Professeur TOP` dans l'UI. +- README mis a jour pour refleter la TTS OpenAI au lieu de la voix navigateur. +- Le frontend n'a pas encore ete verifie par build local car `frontend/node_modules` est absent. +- Lors d'un prochain demarrage, penser a installer les dependances frontend avant validation finale. +- La TTS OpenAI suppose une `OPENAI_API_KEY` valide cote backend. ## Points de Vigilance - Les erreurs WebSocket Vite/HMR sur `wss://prof.open-squared.tech/...` sont du bruit de dev tant que le frontend tourne via Vite derriere nginx. - Ces erreurs ne sont pas la cause principale si `/api/*` renvoie des `502` ou si le micro se comporte mal. - En cas de `502` sur `/api/*`, verifier d'abord `docker logs tutor-backend`. +- Pour tester l'auth locale actuelle: `.env` contient `ADMIN_USERNAME=admin` et `ADMIN_PASSWORD=admin`; a changer avant tout usage partage. + +## Plan de Dev Acces Eleve / Admin + +1. Socle comptes et roles: table `users`, hash password, seed admin depuis `.env`, login, session courante. +2. Separation UI: `LoginPage`, `StudentApp`, `AdminApp`, composants partages. +3. Conversation persistante: endpoints de lecture messages, puis sessions et rattachement des messages/tentatives. +4. UI eleve sans scroll: Professeur TOP en haut, log de seance a gauche, derniere instruction au centre, saisie/micro en bas. +5. Dashboard prof/admin: creation eleves/comptes, rapports, historique seances, conversation complete, stats de reussite. ## Fichiers Touchés Pendant Cette Session - `frontend/src/App.jsx` +- `frontend/src/styles.css` +- `frontend/src/assets/professeur-top.png` - `frontend/vite.config.js` - `backend/app/main.py` +- `backend/app/schemas.py` - `backend/app/services.py` - `backend/requirements.txt` +- `README.md` +- `MEMORY.md` diff --git a/README.md b/README.md index 9e71afa..343905f 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,21 @@ cp .env.example .env 2. Ajouter votre clé OpenAI dans `.env`. -3. Lancer : +3. Configurer le compte prof/admin initial dans `.env` : + +```bash +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +AUTH_SECRET_KEY=replace-with-a-long-random-secret +``` + +4. Lancer : ```bash docker compose up --build ``` -4. Ouvrir : +5. Ouvrir : - Frontend : http://localhost:3000 - API : http://localhost:8000/docs diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..1f7121f --- /dev/null +++ b/backend/app/auth.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py index 765cf84..9c466a0 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 7143722..488abb5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models.py b/backend/app/models.py index 34ab23d..4f3f906 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c7f5bf8..44ea4e7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index bf7c8ea..6ef33ae 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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