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

6
.env
View File

@@ -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

View File

@@ -1 +1,5 @@
OPENAI_API_KEY=sk-... OPENAI_API_KEY=sk-...
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
AUTH_SECRET_KEY=replace-with-a-long-random-secret
AUTH_COOKIE_SECURE=false

View File

@@ -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 frontend appelle l'API via le prefixe `/api`.
- Le backend expose une route `POST /transcribe` pour la transcription audio. - Le backend expose une route `POST /transcribe` pour la transcription audio.
- La route `/transcribe` utilise `UploadFile`, donc `python-multipart` est requis dans le backend. - 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. - 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: - La conf nginx reelle route:
- `/api/` vers `tutor-backend:8000` - `/api/` vers `tutor-backend:8000`
@@ -36,21 +39,68 @@ Ce projet est un POC de professeur virtuel pour enfants, avec:
## Voix du Professeur ## Voix du Professeur
- Le frontend charge les voix du navigateur via `speechSynthesis.getVoices()`. - L'ancien systeme de voix navigateur a ete remplace par une vraie TTS OpenAI.
- Une liste de voix est proposee dans l'interface. - Le backend expose maintenant:
- Les voix francaises sont priorisees dans le tri. - `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 ## 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. - 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. - 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`. - 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 ## Fichiers Touchés Pendant Cette Session
- `frontend/src/App.jsx` - `frontend/src/App.jsx`
- `frontend/src/styles.css`
- `frontend/src/assets/professeur-top.png`
- `frontend/vite.config.js` - `frontend/vite.config.js`
- `backend/app/main.py` - `backend/app/main.py`
- `backend/app/schemas.py`
- `backend/app/services.py` - `backend/app/services.py`
- `backend/requirements.txt` - `backend/requirements.txt`
- `README.md`
- `MEMORY.md`

View File

@@ -18,13 +18,21 @@ cp .env.example .env
2. Ajouter votre clé OpenAI dans `.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 ```bash
docker compose up --build docker compose up --build
``` ```
4. Ouvrir : 5. Ouvrir :
- Frontend : http://localhost:3000 - Frontend : http://localhost:3000
- API : http://localhost:8000/docs - API : http://localhost:8000/docs

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 import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy.orm import declarative_base, sessionmaker
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./local.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./local.db")

View File

@@ -1,9 +1,17 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import Base, engine, get_db from .database import Base, engine, get_db
from . import models, schemas 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 .curriculum import QUESTIONS
from .services import ( from .services import (
build_llm_reply, build_llm_reply,
@@ -23,6 +31,7 @@ async def lifespan(app: FastAPI):
db = next(get_db()) db = next(get_db())
try: try:
seed_skills(db) seed_skills(db)
seed_admin_user(db)
finally: finally:
db.close() db.close()
yield yield
@@ -47,6 +56,36 @@ def health():
return {"status": "ok"} 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]) @app.get("/students", response_model=list[schemas.StudentRead])
def list_students(db: Session = Depends(get_db)): def list_students(db: Session = Depends(get_db)):
return db.query(models.Student).order_by(models.Student.id.asc()).all() 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") 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): class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"

View File

@@ -18,6 +18,27 @@ class StudentRead(BaseModel):
from_attributes = True 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): class ChatRequest(BaseModel):
student_id: int student_id: int
message: str message: str

View File

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