login passwd
This commit is contained in:
6
.env
6
.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
|
||||
|
||||
@@ -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
|
||||
|
||||
56
MEMORY.md
56
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`
|
||||
|
||||
|
||||
12
README.md
12
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
|
||||
|
||||
|
||||
126
backend/app/auth.py
Normal file
126
backend/app/auth.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user